1
0
mirror of https://github.com/sasjs/adapter.git synced 2025-12-11 01:14:36 +00:00

Compare commits

...

63 Commits

Author SHA1 Message Date
Krishna Acondy
1d31ec4dcc Merge pull request #218 from sasjs/axios-fetch
fix(*): replace native fetch calls with axios
2021-02-15 12:50:13 +00:00
Krishna Acondy
4407ed68ae Merge branch 'master' of https://github.com/sasjs/adapter into axios-fetch 2021-02-15 12:46:11 +00:00
Krishna Acondy
b3359f2138 Merge pull request #241 from sasjs/dependabot/npm_and_yarn/webpack-5.21.2
chore(deps-dev): bump webpack from 5.13.0 to 5.21.2
2021-02-15 08:43:03 +00:00
dependabot-preview[bot]
cff9104adf chore(deps-dev): bump webpack from 5.13.0 to 5.21.2
Bumps [webpack](https://github.com/webpack/webpack) from 5.13.0 to 5.21.2.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.13.0...v5.21.2)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-02-15 08:41:19 +00:00
Krishna Acondy
b73f821c19 Merge pull request #250 from sasjs/dependabot/npm_and_yarn/sasjs/utils-2.5.0
chore(deps): bump @sasjs/utils from 2.0.2 to 2.5.0
2021-02-15 08:39:09 +00:00
dependabot-preview[bot]
dd3c1a7375 chore(deps): bump @sasjs/utils from 2.0.2 to 2.5.0
Bumps [@sasjs/utils](https://github.com/sasjs/utils) from 2.0.2 to 2.5.0.
- [Release notes](https://github.com/sasjs/utils/releases)
- [Commits](https://github.com/sasjs/utils/compare/v2.0.2...v2.5.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-02-15 08:37:17 +00:00
Krishna Acondy
c10b5368af Merge pull request #251 from sasjs/dependabot/npm_and_yarn/semantic-release-17.3.9
chore(deps-dev): bump semantic-release from 17.3.1 to 17.3.9
2021-02-15 08:35:10 +00:00
dependabot-preview[bot]
2df66765c0 chore(deps-dev): bump semantic-release from 17.3.1 to 17.3.9
Bumps [semantic-release](https://github.com/semantic-release/semantic-release) from 17.3.1 to 17.3.9.
- [Release notes](https://github.com/semantic-release/semantic-release/releases)
- [Commits](https://github.com/semantic-release/semantic-release/compare/v17.3.1...v17.3.9)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-02-15 08:29:58 +00:00
Krishna Acondy
ed65545571 Merge pull request #248 from sasjs/dependabot/npm_and_yarn/ts-loader-8.0.17
chore(deps-dev): bump ts-loader from 8.0.14 to 8.0.17
2021-02-15 08:27:46 +00:00
dependabot-preview[bot]
b4ae486520 chore(deps-dev): bump ts-loader from 8.0.14 to 8.0.17
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 8.0.14 to 8.0.17.
- [Release notes](https://github.com/TypeStrong/ts-loader/releases)
- [Changelog](https://github.com/TypeStrong/ts-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/ts-loader/compare/v8.0.14...v8.0.17)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-02-15 08:23:51 +00:00
Krishna Acondy
bb7b3d0b84 Merge pull request #249 from sasjs/dependabot/npm_and_yarn/typescript-3.9.9
chore(deps-dev): bump typescript from 3.9.7 to 3.9.9
2021-02-15 08:21:44 +00:00
dependabot-preview[bot]
65b34aa015 chore(deps-dev): bump typescript from 3.9.7 to 3.9.9
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 3.9.7 to 3.9.9.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v3.9.7...v3.9.9)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-02-15 08:12:05 +00:00
Krishna Acondy
b3c9303def Merge pull request #235 from sasjs/dependabot/npm_and_yarn/webpack-cli-4.5.0
chore(deps-dev): bump webpack-cli from 4.3.1 to 4.5.0
2021-02-15 08:09:42 +00:00
dependabot-preview[bot]
19dfda9b6a chore(deps-dev): bump webpack-cli from 4.3.1 to 4.5.0
Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 4.3.1 to 4.5.0.
- [Release notes](https://github.com/webpack/webpack-cli/releases)
- [Changelog](https://github.com/webpack/webpack-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-cli/compare/webpack-cli@4.3.1...webpack-cli@4.5.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-02-15 08:01:11 +00:00
Krishna Acondy
ce2abb448f Merge pull request #217 from sasjs/dependabot/npm_and_yarn/typedoc-neo-theme-1.1.0
chore(deps-dev): bump typedoc-neo-theme from 1.0.10 to 1.1.0
2021-02-15 07:59:06 +00:00
Krishna Acondy
413c3b8098 Merge branch 'axios-fetch' of https://github.com/sasjs/adapter into axios-fetch 2021-02-10 08:19:51 +00:00
Krishna Acondy
269514a44f fix(*): expose CSRF token, add getFolder API 2021-02-10 08:19:48 +00:00
Saad Jutt
5fc334dd8b chore: param 'insecure' removed 2021-02-09 17:57:01 +05:00
Krishna Acondy
1b251f1cea fix(auth): auto submit auth form if needed 2021-02-08 19:07:37 +00:00
Krishna Acondy
70d3e25c7f Merge branch 'axios-fetch' of https://github.com/sasjs/adapter into axios-fetch 2021-02-08 08:31:55 +00:00
Krishna Acondy
5e9b33e346 chore(*): fix sasjs tests 2021-02-08 08:31:42 +00:00
Krishna Acondy
eb52ec7532 Merge branch 'master' into axios-fetch 2021-02-06 13:23:30 +00:00
Krishna Acondy
f46c4bf3ca Merge branch 'axios-fetch' of https://github.com/sasjs/adapter into axios-fetch 2021-02-06 13:00:31 +00:00
Krishna Acondy
d8176912cf fix(requests): only allow insecure requests if https module is available 2021-02-06 13:00:27 +00:00
dependabot-preview[bot]
4c54ade2d3 chore(deps-dev): bump typedoc-neo-theme from 1.0.10 to 1.1.0
Bumps [typedoc-neo-theme](https://github.com/google/typedoc-neo-theme) from 1.0.10 to 1.1.0.
- [Release notes](https://github.com/google/typedoc-neo-theme/releases)
- [Commits](https://github.com/google/typedoc-neo-theme/commits)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2021-02-06 06:54:47 +00:00
Yury Shkoda
52c41dfb3a Merge pull request #196 from sasjs/releaseScript
Release script
2021-02-06 09:52:44 +03:00
Yury Shkoda
36f0aa7411 Merge branch 'master' into releaseScript 2021-02-06 09:31:21 +03:00
Saad Jutt
a10e4ec264 fix(*): fix invalid typing 2021-02-05 19:03:20 +05:00
Krishna Acondy
851b6bc273 chore(*): change import to require 2021-02-05 12:21:33 +00:00
Krishna Acondy
23f8d31b1b fix(*): support multipart form data post 2021-02-05 11:10:57 +00:00
Krishna Acondy
a5683bcd07 fix(insecure-requests): add flag to config 2021-02-05 09:33:42 +00:00
Krishna Acondy
c7be71c781 fix(*): add the ability to ignore SSL errors 2021-02-05 08:56:40 +00:00
Krishna Acondy
594f274323 chore(*): bump dependencies for sasjs-tests 2021-02-05 08:24:13 +00:00
Krishna Acondy
ba1ed5e732 fix(job-execution): throw error if job name is empty 2021-02-05 08:23:25 +00:00
Krishna Acondy
613cc6b9ef chore(*): clean up, handle debug responses 2021-02-04 09:42:39 +00:00
Krishna Acondy
8edb00f869 fix(request-client): handle SAS9 error scenarios, clear CSRF tokens on log out 2021-02-03 09:34:34 +00:00
Krishna Acondy
60a1f84604 chore(*): clean up, address review comments 2021-02-02 08:42:01 +00:00
Krishna Acondy
36cfaee5db chore(*): fix package.json format 2021-02-02 08:21:25 +00:00
Krishna Acondy
cb607c93ca Merge branch 'master' into axios-fetch 2021-02-02 08:19:53 +00:00
Krishna Acondy
03b5e1d824 chore(*): refactor common functionality into JobExecutor, handle all auth-related scenarios 2021-02-02 08:18:48 +00:00
Krishna Acondy
2ea49a425f fix(*): post request bodies as JSON 2021-01-28 20:36:41 +00:00
Krishna Acondy
3c894c4147 fix(*): handled 404s, set correct accept headers 2021-01-28 19:25:23 +00:00
Krishna Acondy
23d151c919 chore(*): commit missed file 2021-01-28 17:02:19 +00:00
Krishna Acondy
6d1c4ff81a fix(*): fix compute job execution 2021-01-28 17:01:59 +00:00
Krishna Acondy
0eba6bdcf4 chore(*): fix tests 2021-01-27 22:03:40 +00:00
Krishna Acondy
d7ecaf5932 fix(*): separate job execution code from main SASjs class 2021-01-27 20:30:13 +00:00
Krishna Acondy
e0d85f458b fix(*): store CSRF tokens in Request Client 2021-01-24 18:23:18 +00:00
Krishna Acondy
3a9cd46e6e chore(*): use server type from utils types 2021-01-24 16:02:34 +00:00
Krishna Acondy
301edab8ad chore(*): refactor and add tests 2021-01-24 15:08:35 +00:00
Krishna Acondy
aed39c2ec4 chore(*): refactor, move all auth-related code to auth folder 2021-01-24 13:07:15 +00:00
Krishna Acondy
e31774ae9d fix(*): fix login issue 2021-01-19 09:29:29 +00:00
Krishna Acondy
00f09179a8 chore(*): remove unnecessary imports 2021-01-19 09:07:40 +00:00
Krishna Acondy
4196901e01 chore(*): remove comment 2021-01-19 08:17:42 +00:00
Krishna Acondy
bf35dd072a chore(*): fix failing tests 2021-01-19 08:16:08 +00:00
Krishna Acondy
75e3fd018d chore(*): replace fetch calls with axios 2021-01-18 09:22:10 +00:00
Krishna Acondy
965dfff7c6 chore(*): use axios instead of fetch 2021-01-18 09:07:36 +00:00
Krishna Acondy
ff64dd22ad chore(*): use local axios instance 2021-01-18 09:01:07 +00:00
Krishna Acondy
e7cceab065 chore(*): add axios 2021-01-18 09:00:16 +00:00
Krishna Acondy
f789b8f7a2 fix(*): extracted auth logic into separate class, used axios instead of fetch 2021-01-18 08:59:58 +00:00
Mihajlo Medjedovic
a2906abf71 Merge branch 'master' into releaseScript 2020-12-30 13:40:22 +01:00
Mihajlo Medjedovic
1555afe771 ci: slack webhook secret update 2020-11-26 10:06:47 +01:00
Mihajlo Medjedovic
a2832f1e1a ci: slack webhook secret 2020-11-25 17:09:05 +01:00
Mihajlo Medjedovic
56f34508fa ci: after release ci will send slack message 2020-11-25 16:40:04 +01:00
49 changed files with 11439 additions and 8532 deletions

View File

@@ -25,3 +25,5 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Send Slack message
run: curl -X POST --data-urlencode "payload={\"channel\":\"#sasjs\", \"username\":\"GitHub CI\", \"text\":\"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.\", \"icon_emoji\":\":rocket:\"}" ${{ secrets.SLACK_WEBHOOK }}

View File

@@ -1,13 +0,0 @@
{
"defaultCommandTimeout": 10000,
"chromeWebSecurity": false,
"screenshotOnRunFailure": false,
"env": {
"serverUrl": "",
"appLoc": "/Public/app",
"serverType": "SAS9",
"debug": false,
"username": "",
"password": ""
}
}

View File

@@ -1,10 +1,184 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
roots: ["<rootDir>/src"],
testMatch: [
"**/__tests__/**/*.+(ts|tsx|js)",
"**/?(*.)+(spec|test).+(ts|tsx|js)"
],
testTimeout: 90000,
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 1,
// Respect "browser" field in package.json when resolving modules
// browser: false,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/7y/nmqg1srj29q6210rs9dfsdzc0000gn/T/jest_dx",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "json",
// "jsx",
// "ts",
// "tsx",
// "node"
// ],
// A map from regular expressions to module names that allow to stub out resources with a single module
moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: 'ts-jest/presets/js-with-ts',
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
setupFilesAfterEnv: ['jest-extended'],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
testMatch: ['**/*spec.[j|t]s?(x)'],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ['/node_modules/', '/build'],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
transform: {
"^.+\\.(ts|tsx)$": "ts-jest"
'^.+\\.ts?$': 'ts-jest'
}
};
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// '**/test/**/*.ts?(x)',
// '**/?(*.)+(spec|test).ts?(x)'
// ]
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
}

3290
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"publish:lib": "npm run build && cd build && npm publish",
"lint:fix": "npx prettier --write 'src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'",
"lint": "npx prettier --check 'src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'",
"test": "jest --coverage",
"test": "jest --silent --coverage",
"prepublishOnly": "cp -r ./build/* . && rm -rf ./build",
"postpublish": "git clean -fd",
"semantic-release": "semantic-release",
@@ -36,32 +36,31 @@
},
"license": "ISC",
"devDependencies": {
"@types/isomorphic-fetch": "0.0.35",
"@types/jest": "^26.0.20",
"cp": "^0.2.0",
"dotenv": "^8.2.0",
"jest": "^25.5.4",
"jest": "^26.6.3",
"jest-extended": "^0.11.5",
"path": "^0.12.7",
"rimraf": "^3.0.2",
"semantic-release": "^17.3.1",
"semantic-release": "^17.3.9",
"terser-webpack-plugin": "^4.2.3",
"ts-jest": "^25.5.1",
"ts-loader": "^8.0.14",
"ts-loader": "^8.0.17",
"tslint": "^6.1.3",
"tslint-config-prettier": "^1.18.0",
"typedoc": "^0.19.2",
"typedoc-neo-theme": "^1.0.10",
"typedoc-neo-theme": "^1.1.0",
"typedoc-plugin-external-module-name": "^4.0.6",
"typescript": "^3.9.7",
"webpack": "^5.13.0",
"webpack-cli": "^4.3.1"
"typescript": "^3.9.9",
"webpack": "^5.21.2",
"webpack-cli": "^4.5.0"
},
"main": "index.js",
"dependencies": {
"@sasjs/utils": "^2.0.2",
"es6-promise": "^4.2.8",
"axios": "^0.21.1",
"@sasjs/utils": "^2.5.0",
"form-data": "^3.0.0",
"https": "^1.0.0",
"isomorphic-fetch": "^2.2.1"
"https": "^1.0.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,21 +4,18 @@
"homepage": ".",
"private": true,
"dependencies": {
"@sasjs/adapter": "^1.18.2",
"@sasjs/adapter": "^2.1.0",
"@sasjs/test-framework": "^1.4.0",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"@types/jest": "^26.0.3",
"@types/node": "^14.0.14",
"@types/react": "^16.9.41",
"@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.5",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.25",
"@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.1.7",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.1",
"typescript": "^3.9.6"
"react-scripts": "^4.0.2",
"typescript": "^4.1.3"
},
"scripts": {
"start": "react-scripts start",
@@ -45,6 +42,6 @@
]
},
"devDependencies": {
"node-sass": "^4.14.1"
"node-sass": "^5.0.0"
}
}

View File

@@ -1,15 +1,17 @@
import SASjs, { ServerType, SASjsConfig } from "@sasjs/adapter";
import SASjs, { SASjsConfig } from "@sasjs/adapter";
import { TestSuite } from "@sasjs/test-framework";
import { ServerType } from "@sasjs/utils/types";
const defaultConfig: SASjsConfig = {
serverUrl: window.location.origin,
pathSAS9: '/SASStoredProcess/do',
pathSASViya: '/SASJobExecution',
appLoc: '/Public/seedapp',
serverType: ServerType.SASViya,
pathSAS9: "/SASStoredProcess/do",
pathSASViya: "/SASJobExecution",
appLoc: "/Public/seedapp",
serverType: ServerType.SasViya,
debug: false,
contextName: 'SAS Job Execution compute context',
useComputeApi: false
contextName: "SAS Job Execution compute context",
useComputeApi: false,
allowInsecureRequests: false
};
const customConfig = {
@@ -17,7 +19,7 @@ const customConfig = {
pathSAS9: "sas9",
pathSASViya: "viya",
appLoc: "/Public/seedapp",
serverType: ServerType.SAS9,
serverType: ServerType.Sas9,
debug: false
};
@@ -39,11 +41,12 @@ export const basicTests = (
},
{
title: "Multiple Log in attempts",
description: "Should fail on first attempt and should log the user in on second attempt",
description:
"Should fail on first attempt and should log the user in on second attempt",
test: async () => {
await adapter.logOut()
await adapter.logIn('invalid', 'invalid')
return adapter.logIn(userName, password)
await adapter.logOut();
await adapter.logIn("invalid", "invalid");
return adapter.logIn(userName, password);
},
assertion: (response: any) =>
response && response.isLoggedIn && response.userName === userName

View File

@@ -12,7 +12,7 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
return adapter.startComputeJob("/Public/app/common/sendArr", data);
},
assertion: (res: any) => {
const expectedProperties = ["id", "applicationName", "attributes"]
const expectedProperties = ["id", "applicationName", "attributes"];
return validate(expectedProperties, res);
}
},
@@ -21,11 +21,22 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
description: "Should start a compute job and return the job",
test: () => {
const data: any = { table1: [{ col1: "first col value" }] };
return adapter.startComputeJob("/Public/app/common/sendArr", data, {}, "", true);
return adapter.startComputeJob(
"/Public/app/common/sendArr",
data,
{},
"",
true
);
},
assertion: (res: any) => {
const expectedProperties = ["id", "state", "creationTimeStamp", "jobConditionCode"]
return validate(expectedProperties, res.result);
const expectedProperties = [
"id",
"state",
"creationTimeStamp",
"jobConditionCode"
];
return validate(expectedProperties, res.job);
}
},
{
@@ -38,19 +49,19 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
`output;`,
`end;`,
`run;`
]
];
return adapter.executeScriptSASViya(
'sasCode.sas',
"sasCode.sas",
fileLines,
'SAS Studio compute context',
"SAS Studio compute context",
undefined,
true
)
);
},
assertion: (res: any) => {
const expectedLogContent = `1 data;\\n2 do x=1 to 100;\\n3 output;\\n4 end;\\n5 run;\\n\\n`
const expectedLogContent = `1 data;\\n2 do x=1 to 100;\\n3 output;\\n4 end;\\n5 run;\\n\\n`;
return validateLog(expectedLogContent, res.log);
}
},
@@ -58,21 +69,21 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
title: "Execute Script Viya - failed job",
description: "Should execute sas file and return log",
test: () => {
const fileLines = [
`%abort;`
]
return adapter.executeScriptSASViya(
'sasCode.sas',
fileLines,
'SAS Studio compute context',
undefined,
true
).catch((err: any) => err )
const fileLines = [`%abort;`];
return adapter
.executeScriptSASViya(
"sasCode.sas",
fileLines,
"SAS Studio compute context",
undefined,
true
)
.catch((err: any) => err);
},
assertion: (res: any) => {
const expectedLogContent = `1 %abort;\\nERROR: The %ABORT statement is not valid in open code.\\n`
const expectedLogContent = `1 %abort;\\nERROR: The %ABORT statement is not valid in open code.\\n`;
return validateLog(expectedLogContent, res.log);
}
}
@@ -80,16 +91,16 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
});
const validateLog = (text: string, log: string): boolean => {
const isValid = JSON.stringify(log).includes(text)
const isValid = JSON.stringify(log).includes(text);
return isValid
}
return isValid;
};
const validate = (expectedProperties: string[], data: any): boolean => {
const actualProperties = Object.keys(data);
const isValid = expectedProperties.every(
(property) => actualProperties.includes(property)
const isValid = expectedProperties.every((property) =>
actualProperties.includes(property)
);
return isValid
}
return isValid;
};

View File

@@ -185,7 +185,8 @@ export const sendObjTests = (adapter: SASjs): TestSuite => ({
};
return adapter.request("common/sendObj", invalidData).catch((e) => e);
},
assertion: (error: any) => !!error && !!error.error && !!error.error.message
assertion: (error: any) =>
!!error && !!error.error && !!error.error.message
},
{
title: "Single string value",

View File

@@ -23,26 +23,23 @@ export const sasjsRequestTests = (adapter: SASjs): TestSuite => ({
},
{
title: "Make error and capture log",
description: "Should make an error and capture log, in the same time it is testing if debug override is working",
description:
"Should make an error and capture log, in the same time it is testing if debug override is working",
test: async () => {
return new Promise(async (resolve, reject) => {
adapter
.request("common/makeErr", data, {debug: true})
.then((res) => {
//no action here, this request must throw error
})
.catch((err) => {
let sasRequests = adapter.getSasRequests();
let makeErrRequest: any =
sasRequests.find((req) =>
req.serviceLink.includes("makeErr")
) || null;
return adapter
.request("common/makeErr", data, { debug: true })
.catch(() => {
const sasRequests = adapter.getSasRequests();
const makeErrRequest: any =
sasRequests.find((req) => req.serviceLink.includes("makeErr")) ||
null;
if (!makeErrRequest) resolve(false)
if (!makeErrRequest) return false;
resolve(!!(makeErrRequest.logFile && makeErrRequest.logFile.length > 0));
});
});
return !!(
makeErrRequest.logFile && makeErrRequest.logFile.length > 0
);
});
},
assertion: (response) => {
return response;

View File

@@ -17,7 +17,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
"jsx": "react-jsx",
"noFallthroughCasesInSwitch": true
},
"include": [
"src"

View File

@@ -1,12 +1,7 @@
import {
Context,
CsrfToken,
EditContextInput,
ContextAllAttributes
} from './types'
import { makeRequest, isUrl } from './utils'
import { SASViyaApiClient } from './SASViyaApiClient'
import { Context, EditContextInput, ContextAllAttributes } from './types'
import { isUrl } from './utils'
import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from './request/RequestClient'
export class ContextManager {
private defaultComputeContexts = [
@@ -29,8 +24,6 @@ export class ContextManager {
'SAS Visual Forecasting launcher context'
]
private csrfToken: CsrfToken | null = null
get getDefaultComputeContexts() {
return this.defaultComputeContexts
}
@@ -38,28 +31,19 @@ export class ContextManager {
return this.defaultLauncherContexts
}
constructor(
private serverUrl: string,
private setCsrfToken: (csrfToken: CsrfToken) => void
) {
constructor(private serverUrl: string, private requestClient: RequestClient) {
if (serverUrl) isUrl(serverUrl)
}
public async getComputeContexts(accessToken?: string) {
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const { result: contexts } = await this.request<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?limit=10000`,
{ headers }
).catch((err) => {
throw prefixMessage(err, 'Error while getting compute contexts. ')
})
const { result: contexts } = await this.requestClient
.get<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?limit=10000`,
accessToken
)
.catch((err) => {
throw prefixMessage(err, 'Error while getting compute contexts. ')
})
const contextsList = contexts && contexts.items ? contexts.items : []
@@ -73,20 +57,14 @@ export class ContextManager {
}
public async getLauncherContexts(accessToken?: string) {
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const { result: contexts } = await this.request<{ items: Context[] }>(
`${this.serverUrl}/launcher/contexts?limit=10000`,
{ headers }
).catch((err) => {
throw prefixMessage(err, 'Error while getting launcher contexts. ')
})
const { result: contexts } = await this.requestClient
.get<{ items: Context[] }>(
`${this.serverUrl}/launcher/contexts?limit=10000`,
accessToken
)
.catch((err) => {
throw prefixMessage(err, 'Error while getting launcher contexts. ')
})
const contextsList = contexts && contexts.items ? contexts.items : []
@@ -184,18 +162,15 @@ export class ContextManager {
requestBody.environment = { autoExecLines }
}
const createContextRequest: RequestInit = {
method: 'POST',
headers,
body: JSON.stringify(requestBody)
}
const { result: context } = await this.request<Context>(
`${this.serverUrl}/compute/contexts`,
createContextRequest
).catch((err) => {
throw prefixMessage(err, 'Error while creating compute context. ')
})
const { result: context } = await this.requestClient
.post<Context>(
`${this.serverUrl}/compute/contexts`,
requestBody,
accessToken
)
.catch((err) => {
throw prefixMessage(err, 'Error while creating compute context. ')
})
return context
}
@@ -238,18 +213,15 @@ export class ContextManager {
launchType
}
const createContextRequest: RequestInit = {
method: 'POST',
headers,
body: JSON.stringify(requestBody)
}
const { result: context } = await this.request<Context>(
`${this.serverUrl}/launcher/contexts`,
createContextRequest
).catch((err) => {
throw prefixMessage(err, 'Error while creating launcher context. ')
})
const { result: context } = await this.requestClient
.post<Context>(
`${this.serverUrl}/launcher/contexts`,
requestBody,
accessToken
)
.catch((err) => {
throw prefixMessage(err, 'Error while creating launcher context. ')
})
return context
}
@@ -268,14 +240,6 @@ export class ContextManager {
true
)
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
let originalContext
originalContext = await this.getComputeContextByName(
@@ -291,39 +255,33 @@ export class ContextManager {
)
}
const { result: context, etag } = await this.request<Context>(
`${this.serverUrl}/compute/contexts/${originalContext.id}`,
{
headers
}
).catch((err) => {
if (err && err.status === 404) {
throw new Error(
`The context '${contextName}' was not found on this server.`
)
}
const { result: context, etag } = await this.requestClient
.get<Context>(
`${this.serverUrl}/compute/contexts/${originalContext.id}`,
accessToken
)
.catch((err) => {
if (err && err.status === 404) {
throw new Error(
`The context '${contextName}' was not found on this server.`
)
}
throw err
})
throw err
})
// An If-Match header with the value of the last ETag for the context
// is required to be able to update it
// https://developer.sas.com/apis/rest/Compute/#update-a-context-definition
headers['If-Match'] = etag
const updateContextRequest: RequestInit = {
method: 'PUT',
headers,
body: JSON.stringify({
return await this.requestClient.put<Context>(
`/compute/contexts/${context.id}`,
{
...context,
...editedContext,
attributes: { ...context.attributes, ...editedContext.attributes }
})
}
return await this.request<Context>(
`${this.serverUrl}/compute/contexts/${context.id}`,
updateContextRequest
},
accessToken,
{ 'If-Match': etag }
)
}
@@ -331,20 +289,17 @@ export class ContextManager {
contextName: string,
accessToken?: string
): Promise<Context> {
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const { result: contexts } = await this.request<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?filter=eq(name, "${contextName}")`,
{ headers }
).catch((err) => {
throw prefixMessage(err, 'Error while getting compute context by name. ')
})
const { result: contexts } = await this.requestClient
.get<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?filter=eq(name, "${contextName}")`,
accessToken
)
.catch((err) => {
throw prefixMessage(
err,
'Error while getting compute context by name. '
)
})
if (!contexts || !(contexts.items && contexts.items.length)) {
throw new Error(
@@ -359,20 +314,16 @@ export class ContextManager {
contextId: string,
accessToken?: string
): Promise<ContextAllAttributes> {
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const { result: context } = await this.request<ContextAllAttributes>(
`${this.serverUrl}/compute/contexts/${contextId}`,
{ headers }
).catch((err) => {
throw prefixMessage(err, 'Error while getting compute context by id. ')
})
const {
result: context
} = await this.requestClient
.get<ContextAllAttributes>(
`${this.serverUrl}/compute/contexts/${contextId}`,
accessToken
)
.catch((err) => {
throw prefixMessage(err, 'Error while getting compute context by id. ')
})
return context
}
@@ -381,20 +332,14 @@ export class ContextManager {
executeScript: Function,
accessToken?: string
) {
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const { result: contexts } = await this.request<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?limit=10000`,
{ headers }
).catch((err) => {
throw prefixMessage(err, 'Error while fetching compute contexts.')
})
const { result: contexts } = await this.requestClient
.get<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?limit=10000`,
accessToken
)
.catch((err) => {
throw prefixMessage(err, 'Error while fetching compute contexts.')
})
const contextsList = contexts.items || []
const executableContexts: any[] = []
@@ -471,14 +416,9 @@ export class ContextManager {
const context = await this.getComputeContextByName(contextName, accessToken)
const deleteContextRequest: RequestInit = {
method: 'DELETE',
headers
}
return await this.request<Context>(
return await this.requestClient.delete<Context>(
`${this.serverUrl}/compute/contexts/${context.id}`,
deleteContextRequest
accessToken
)
}
@@ -486,34 +426,6 @@ export class ContextManager {
// TODO: implement deleteLauncherContext method
private async request<T>(
url: string,
options: RequestInit,
contentType: 'text' | 'json' = 'json'
) {
if (this.csrfToken) {
options.headers = {
...options.headers,
[this.csrfToken.headerName]: this.csrfToken.value
}
}
return await makeRequest<T>(
url,
options,
(token) => {
this.csrfToken = token
this.setCsrfToken(token)
},
contentType
).catch((err) => {
throw prefixMessage(
err,
'Error while making request in Context Manager. '
)
})
}
private validateContextName(name: string) {
if (!name) throw new Error('Context name is required.')
}

View File

@@ -1,115 +1,70 @@
import { isLogInRequired, needsRetry, isUrl } from './utils'
import { CsrfToken } from './types/CsrfToken'
import { isUrl } from './utils'
import { UploadFile } from './types/UploadFile'
import { ErrorResponse } from './types'
const requestRetryLimit = 5
import { ErrorResponse, LoginRequiredError } from './types'
import { RequestClient } from './request/RequestClient'
export class FileUploader {
constructor(
private appLoc: string,
private serverUrl: string,
serverUrl: string,
private jobsPath: string,
private setCsrfTokenWeb: any,
private csrfToken: CsrfToken | null = null
private requestClient: RequestClient
) {
if (serverUrl) isUrl(serverUrl)
}
private retryCount = 0
public uploadFile(sasJob: string, files: UploadFile[], params: any) {
return new Promise((resolve, reject) => {
if (files?.length < 1)
reject(new ErrorResponse('At least one file must be provided.'))
if (!sasJob || sasJob === '')
reject(new ErrorResponse('sasJob must be provided.'))
if (files?.length < 1)
return Promise.reject(
new ErrorResponse('At least one file must be provided.')
)
if (!sasJob || sasJob === '')
return Promise.reject(new ErrorResponse('sasJob must be provided.'))
let paramsString = ''
let paramsString = ''
for (let param in params) {
if (params.hasOwnProperty(param)) {
paramsString += `&${param}=${params[param]}`
for (let param in params) {
if (params.hasOwnProperty(param)) {
paramsString += `&${param}=${params[param]}`
}
}
const program = this.appLoc
? this.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
const uploadUrl = `${this.jobsPath}/?${
'_program=' + program
}${paramsString}`
const formData = new FormData()
for (let file of files) {
formData.append('file', file.file, file.fileName)
}
const csrfToken = this.requestClient.getCsrfToken('file')
if (csrfToken) formData.append('_csrf', csrfToken.value)
const headers = {
'cache-control': 'no-cache',
Accept: '*/*',
'Content-Type': 'text/plain'
}
return this.requestClient
.post(uploadUrl, formData, undefined, 'application/json', headers)
.then((res) =>
typeof res.result === 'string' ? JSON.parse(res.result) : res.result
)
.catch((err: Error) => {
if (err instanceof LoginRequiredError) {
return Promise.reject(
new ErrorResponse('You must be logged in to upload a file.', err)
)
}
}
const program = this.appLoc
? this.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
const uploadUrl = `${this.serverUrl}${this.jobsPath}/?${
'_program=' + program
}${paramsString}`
const headers = {
'cache-control': 'no-cache'
}
const formData = new FormData()
for (let file of files) {
formData.append('file', file.file, file.fileName)
}
if (this.csrfToken) formData.append('_csrf', this.csrfToken.value)
fetch(uploadUrl, {
method: 'POST',
body: formData,
referrerPolicy: 'same-origin',
headers
return Promise.reject(
new ErrorResponse('File upload request failed.', err)
)
})
.then(async (response) => {
if (!response.ok) {
if (response.status === 403) {
const tokenHeader = response.headers.get('X-CSRF-HEADER')
if (tokenHeader) {
const token = response.headers.get(tokenHeader)
this.csrfToken = {
headerName: tokenHeader,
value: token || ''
}
this.setCsrfTokenWeb(this.csrfToken)
}
}
}
return response.text()
})
.then((responseText) => {
if (isLogInRequired(responseText))
reject(new ErrorResponse('You must be logged in to upload a file.'))
if (needsRetry(responseText)) {
if (this.retryCount < requestRetryLimit) {
this.retryCount++
this.uploadFile(sasJob, files, params).then(
(res: any) => resolve(res),
(err: any) => reject(err)
)
} else {
this.retryCount = 0
reject(responseText)
}
} else {
this.retryCount = 0
try {
resolve(JSON.parse(responseText))
} catch (e) {
reject(
new ErrorResponse(
'Error while parsing json from upload response.',
e
)
)
}
}
})
.catch((err: any) => {
reject(new ErrorResponse('Upload request failed.', err))
})
})
}
}

View File

@@ -1,3 +1,4 @@
import axios, { AxiosInstance } from 'axios'
import { isUrl } from './utils'
/**
@@ -5,8 +6,11 @@ import { isUrl } from './utils'
*
*/
export class SAS9ApiClient {
private httpClient: AxiosInstance
constructor(private serverUrl: string) {
if (serverUrl) isUrl(serverUrl)
this.httpClient = axios.create({ baseURL: this.serverUrl })
}
/**
@@ -38,18 +42,18 @@ export class SAS9ApiClient {
repositoryName: string
) {
const requestPayload = linesOfCode.join('\n')
const executeScriptRequest = {
method: 'PUT',
headers: {
Accept: 'application/json'
},
body: `command=${requestPayload}`
}
const executeScriptResponse = await fetch(
`${this.serverUrl}/sas/servers/${serverName}/cmd?repositoryName=${repositoryName}`,
executeScriptRequest
).then((res) => res.text())
return executeScriptResponse
const executeScriptResponse = await this.httpClient.put(
`/sas/servers/${serverName}/cmd?repositoryName=${repositoryName}`,
`command=${requestPayload}`,
{
headers: {
Accept: 'application/json'
},
responseType: 'text'
}
)
return executeScriptResponse.data
}
}

View File

@@ -1,12 +1,4 @@
import {
isAuthorizeFormRequired,
parseAndSubmitAuthorizeForm,
convertToCSV,
makeRequest,
isRelativePath,
isUri,
isUrl
} from './utils'
import { convertToCSV, isRelativePath, isUri, isUrl } from './utils'
import * as NodeFormData from 'form-data'
import {
Job,
@@ -14,16 +6,21 @@ import {
Context,
ContextAllAttributes,
Folder,
CsrfToken,
EditContextInput,
JobDefinition,
PollOptions
PollOptions,
ComputeJobExecutionError,
JobExecutionError
} from './types'
import { formatDataForRequest } from './utils/formatDataForRequest'
import { SessionManager } from './SessionManager'
import { ContextManager } from './ContextManager'
import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time'
import { Logger, LogLevel } from '@sasjs/utils/logger'
import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired'
import { RequestClient } from './request/RequestClient'
import { NotFoundError } from './types/NotFoundError'
import { SasAuthResponse } from '@sasjs/utils/types'
/**
* A client for interfacing with the SAS Viya REST API.
@@ -34,20 +31,21 @@ export class SASViyaApiClient {
private serverUrl: string,
private rootFolderName: string,
private contextName: string,
private setCsrfToken: (csrfToken: CsrfToken) => void
private requestClient: RequestClient
) {
if (serverUrl) isUrl(serverUrl)
}
private csrfToken: CsrfToken | null = null
private fileUploadCsrfToken: CsrfToken | null = null
private _debug = false
private sessionManager = new SessionManager(
this.serverUrl,
this.contextName,
this.setCsrfToken
this.requestClient
)
private contextManager = new ContextManager(
this.serverUrl,
this.requestClient
)
private contextManager = new ContextManager(this.serverUrl, this.setCsrfToken)
private folderMap = new Map<string, Job[]>()
public get debug() {
@@ -146,10 +144,10 @@ export class SASViyaApiClient {
headers.Authorization = `Bearer ${accessToken}`
}
const { result: contexts } = await this.request<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?limit=10000`,
{ headers }
)
const { result: contexts } = await this.requestClient.get<{
items: Context[]
}>(`/compute/contexts?limit=10000`, accessToken)
const executionContext =
contexts.items && contexts.items.length
? contexts.items.find((c: any) => c.name === contextName)
@@ -165,9 +163,10 @@ export class SASViyaApiClient {
'Content-Type': 'application/json'
}
}
const { result: createdSession } = await this.request<Session>(
`${this.serverUrl}/compute/contexts/${executionContext.id}/sessions`,
createSessionRequest
const { result: createdSession } = await this.requestClient.post<Session>(
`/compute/contexts/${executionContext.id}/sessions`,
{},
accessToken
)
return createdSession
@@ -370,24 +369,22 @@ export class SASViyaApiClient {
}
// Execute job in session
const postJobRequest = {
method: 'POST',
headers,
body: JSON.stringify({
name: fileName,
description: 'Powered by SASjs',
code: linesOfCode,
variables: jobVariables,
arguments: jobArguments
})
const jobRequestBody = {
name: fileName,
description: 'Powered by SASjs',
code: linesOfCode,
variables: jobVariables,
arguments: jobArguments
}
const { result: postedJob, etag } = await this.request<Job>(
`${this.serverUrl}/compute/sessions/${executionSessionId}/jobs`,
postJobRequest
).catch((err) => {
throw err
})
const { result: postedJob, etag } = await this.requestClient
.post<Job>(
`/compute/sessions/${executionSessionId}/jobs`,
jobRequestBody,
accessToken
)
.catch((err: any) => {
throw err
})
if (!waitForResult) {
return session
@@ -409,12 +406,14 @@ export class SASViyaApiClient {
pollOptions
)
const { result: currentJob } = await this.request<Job>(
`${this.serverUrl}/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`,
{ headers }
).catch((err) => {
throw err
})
const { result: currentJob } = await this.requestClient
.get<Job>(
`/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`,
accessToken
)
.catch((err) => {
throw err
})
let jobResult
let log
@@ -422,12 +421,8 @@ export class SASViyaApiClient {
const logLink = currentJob.links.find((l) => l.rel === 'log')
if (debug && logLink) {
log = await this.request<any>(
`${this.serverUrl}${logLink.href}/content?limit=10000`,
{
headers
}
)
log = await this.requestClient
.get<any>(`${logLink.href}/content?limit=10000`, accessToken)
.then((res: any) =>
res.result.items.map((i: any) => i.line).join('\n')
)
@@ -437,7 +432,7 @@ export class SASViyaApiClient {
}
if (jobStatus === 'failed' || jobStatus === 'error') {
return Promise.reject({ job: currentJob, log })
return Promise.reject(new ComputeJobExecutionError(currentJob, log))
}
let resultLink
@@ -449,36 +444,30 @@ export class SASViyaApiClient {
}
if (resultLink) {
jobResult = await this.request<any>(
`${this.serverUrl}${resultLink}`,
{ headers },
'text'
).catch(async (e) => {
if (e && e.status === 404) {
if (logLink) {
log = await this.request<any>(
`${this.serverUrl}${logLink.href}/content?limit=10000`,
{
headers
}
)
.then((res: any) =>
res.result.items.map((i: any) => i.line).join('\n')
)
.catch((err) => {
throw err
})
jobResult = await this.requestClient
.get<any>(resultLink, accessToken, 'text/plain')
.catch(async (e) => {
if (e instanceof NotFoundError) {
if (logLink) {
log = await this.requestClient
.get<any>(`${logLink.href}/content?limit=10000`, accessToken)
.then((res: any) =>
res.result.items.map((i: any) => i.line).join('\n')
)
.catch((err) => {
throw err
})
return Promise.reject({
status: 500,
log: log
})
return Promise.reject({
status: 500,
log
})
}
}
}
return {
result: JSON.stringify(e)
}
})
return {
result: JSON.stringify(e)
}
})
}
await this.sessionManager
@@ -506,6 +495,17 @@ export class SASViyaApiClient {
}
}
/**
* Fetches a folder. Path to the folder is required.
* @param folderPath - the absolute path to the folder.
* @param accessToken - an access token for authorizing the request.
*/
public async getFolder(folderPath: string, accessToken?: string) {
return await this.requestClient
.get(`/folders/folders/@item?path=${folderPath}`, accessToken)
.then((res) => res.result)
}
/**
* Creates a folder. Path to or URI of the parent folder is required.
* @param folderName - the name of the new folder.
@@ -568,22 +568,15 @@ export class SASViyaApiClient {
}
}
const createFolderRequest: RequestInit = {
method: 'POST',
body: JSON.stringify({
const {
result: createFolderResponse
} = await this.requestClient.post<Folder>(
`/folders/folders?parentFolderUri=${parentFolderUri}`,
{
name: folderName,
type: 'folder'
})
}
createFolderRequest.headers = { 'Content-Type': 'application/json' }
if (accessToken) {
createFolderRequest.headers.Authorization = `Bearer ${accessToken}`
}
const { result: createFolderResponse } = await this.request<Folder>(
`${this.serverUrl}/folders/folders?parentFolderUri=${parentFolderUri}`,
createFolderRequest
},
accessToken
)
// update folder map with newly created folder.
@@ -617,13 +610,9 @@ export class SASViyaApiClient {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken)
}
const createJobDefinitionRequest: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/vnd.sas.job.definition+json',
Accept: 'application/vnd.sas.job.definition+json'
},
body: JSON.stringify({
return await this.requestClient.post<Job>(
`${this.serverUrl}/jobDefinitions/definitions?parentFolderUri=${parentFolderUri}`,
{
name: jobName,
parameters: [
{
@@ -634,19 +623,8 @@ export class SASViyaApiClient {
],
type: 'Compute',
code
})
}
if (accessToken) {
createJobDefinitionRequest!.headers = {
...createJobDefinitionRequest.headers,
Authorization: `Bearer ${accessToken}`
}
}
return await this.request<Job>(
`${this.serverUrl}/jobDefinitions/definitions?parentFolderUri=${parentFolderUri}`,
createJobDefinitionRequest
},
accessToken
)
}
@@ -657,18 +635,13 @@ export class SASViyaApiClient {
public async getAuthCode(clientId: string) {
const authUrl = `${this.serverUrl}/SASLogon/oauth/authorize?client_id=${clientId}&response_type=code`
const authCode = await fetch(authUrl, {
referrerPolicy: 'same-origin',
credentials: 'include'
})
.then((response) => response.text())
const authCode = await this.requestClient
.get<string>(authUrl, undefined, 'text/plain')
.then((response) => response.result)
.then(async (response) => {
let code = ''
if (isAuthorizeFormRequired(response)) {
const formResponse: any = await parseAndSubmitAuthorizeForm(
response,
this.serverUrl
)
const formResponse: any = await this.requestClient.authorize(response)
const responseBody = formResponse
.split('<body>')[1]
@@ -701,14 +674,12 @@ export class SASViyaApiClient {
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
* @param authCode - the auth code received from the server.
* @param insecure - this boolean tells adapter to ignore SSL errors. [Not Recommended]
*/
public async getAccessToken(
clientId: string,
clientSecret: string,
authCode: string,
insecure: boolean = false
) {
authCode: string
): Promise<SasAuthResponse> {
const url = this.serverUrl + '/SASLogon/oauth/token'
let token
if (typeof Buffer === 'undefined') {
@@ -731,24 +702,15 @@ export class SASViyaApiClient {
formData.append('code', authCode)
}
let moreOptions = {}
if (insecure) {
const https = require('https')
moreOptions = {
agent: new https.Agent({
rejectUnauthorized: false
})
}
}
const authResponse = await fetch(url, {
method: 'POST',
credentials: 'include',
headers,
body: formData as any,
referrerPolicy: 'same-origin',
...moreOptions
}).then((res) => res.json())
const authResponse = await this.requestClient
.post(
url,
formData,
undefined,
'multipart/form-data; boundary=' + (formData as any)._boundary,
headers
)
.then((res) => res.result as SasAuthResponse)
return authResponse
}
@@ -786,13 +748,15 @@ export class SASViyaApiClient {
formData.append('refresh_token', refreshToken)
}
const authResponse = await fetch(url, {
method: 'POST',
credentials: 'include',
headers,
body: formData as any,
referrerPolicy: 'same-origin'
}).then((res) => res.json())
const authResponse = await this.requestClient
.post<SasAuthResponse>(
url,
formData,
undefined,
'multipart/form-data; boundary=' + (formData as any)._boundary,
headers
)
.then((res) => res.result)
return authResponse
}
@@ -808,13 +772,10 @@ export class SASViyaApiClient {
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const deleteResponse = await this.request(url, {
method: 'DELETE',
credentials: 'include',
headers
})
return deleteResponse
const deleteResponse = await this.requestClient.delete(url, accessToken)
return deleteResponse.result
}
/**
@@ -884,9 +845,11 @@ export class SASViyaApiClient {
throw new Error(`URI of job definition was not found.`)
}
const { result: jobDefinition } = await this.request<JobDefinition>(
const {
result: jobDefinition
} = await this.requestClient.get<JobDefinition>(
`${this.serverUrl}${jobDefinitionLink.href}`,
{ headers }
accessToken
)
code = jobDefinition.code
@@ -961,20 +924,10 @@ export class SASViyaApiClient {
const jobDefinitionLink = jobToExecute?.links.find(
(l) => l.rel === 'getResource'
)?.href
const requestInfo: any = {
method: 'GET'
}
const headers: any = { 'Content-Type': 'application/json' }
if (!!accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
requestInfo.headers = headers
const { result: jobDefinition } = await this.request<Job>(
const { result: jobDefinition } = await this.requestClient.get<Job>(
`${this.serverUrl}${jobDefinitionLink}`,
requestInfo
accessToken
)
const jobArguments: { [key: string]: any } = {
@@ -1001,47 +954,46 @@ export class SASViyaApiClient {
jobArguments[`_webin_name${index + 1}`] = fileInfo.tableName
})
const postJobRequest = {
method: 'POST',
headers,
body: JSON.stringify({
name: `exec-${jobName}`,
description: 'Powered by SASjs',
jobDefinition,
arguments: jobArguments
})
const postJobRequestBody = {
name: `exec-${jobName}`,
description: 'Powered by SASjs',
jobDefinition,
arguments: jobArguments
}
const { result: postedJob, etag } = await this.request<Job>(
const { result: postedJob, etag } = await this.requestClient.post<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequest
postJobRequestBody,
accessToken
)
const jobStatus = await this.pollJobState(postedJob, etag, accessToken)
const { result: currentJob } = await this.request<Job>(
const { result: currentJob } = await this.requestClient.get<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
{ headers }
accessToken
)
let jobResult
let log
if (jobStatus === 'failed') {
return Promise.reject(currentJob.error)
}
const resultLink = currentJob.results['_webout.json']
const logLink = currentJob.links.find((l) => l.rel === 'log')
if (resultLink) {
jobResult = await this.request<any>(
jobResult = await this.requestClient.get<any>(
`${this.serverUrl}${resultLink}/content`,
{ headers },
'text'
accessToken,
'text/plain'
)
}
if (debug && logLink) {
log = await this.request<any>(
`${this.serverUrl}${logLink.href}/content`,
{
headers
}
).then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
log = await this.requestClient
.get<any>(`${this.serverUrl}${logLink.href}/content`, accessToken)
.then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
}
if (jobStatus === 'failed') {
throw new JobExecutionError(
currentJob.error?.errorCode,
currentJob.error?.message,
log
)
}
return { result: jobResult?.result, log }
}
@@ -1055,22 +1007,16 @@ export class SASViyaApiClient {
}
const url = '/folders/folders/@item?path=' + path
const requestInfo: any = {
method: 'GET'
}
if (accessToken) {
requestInfo.headers = { Authorization: `Bearer ${accessToken}` }
}
const { result: folder } = await this.request<Folder>(
`${this.serverUrl}${url}`,
requestInfo
const { result: folder } = await this.requestClient.get<Folder>(
`${url}`,
accessToken
)
if (!folder) {
throw new Error(`The path ${path} does not exist on ${this.serverUrl}`)
}
const { result: members } = await this.request<{ items: any[] }>(
`${this.serverUrl}/folders/folders/${folder.id}/members?limit=${folder.memberCount}`,
requestInfo
const { result: members } = await this.requestClient.get<{ items: any[] }>(
`/folders/folders/${folder.id}/members?limit=${folder.memberCount}`,
accessToken
)
const itemsAtRoot = members.items
@@ -1084,7 +1030,7 @@ export class SASViyaApiClient {
accessToken?: string,
pollOptions?: PollOptions
) {
let POLL_INTERVAL = 100
let POLL_INTERVAL = 300
let MAX_POLL_COUNT = 1000
if (pollOptions) {
@@ -1106,12 +1052,10 @@ export class SASViyaApiClient {
Promise.reject(`Job state link was not found.`)
}
const { result: state } = await this.request<string>(
const { result: state } = await this.requestClient.get<string>(
`${this.serverUrl}${stateLink.href}?_action=wait&wait=30`,
{
headers
},
'text'
accessToken,
'text/plain'
)
const currentState = state.trim()
@@ -1129,12 +1073,10 @@ export class SASViyaApiClient {
postedJobState === 'pending'
) {
if (stateLink) {
const { result: jobState } = await this.request<string>(
const { result: jobState } = await this.requestClient.get<string>(
`${this.serverUrl}${stateLink.href}?_action=wait&wait=30`,
{
headers
},
'text'
accessToken,
'text/plain'
)
postedJobState = jobState.trim()
@@ -1177,17 +1119,10 @@ export class SASViyaApiClient {
)
}
const createFileRequest = {
method: 'POST',
body: csv,
headers
}
const uploadResponse = await this.request<any>(
const uploadResponse = await this.requestClient.uploadFile(
`${this.serverUrl}/files/files#rawUpload`,
createFileRequest,
'json',
'fileUpload'
csv,
accessToken
)
uploadedFiles.push({ tableName, file: uploadResponse.result })
@@ -1197,18 +1132,11 @@ export class SASViyaApiClient {
private async getFolderUri(folderPath: string, accessToken?: string) {
const url = '/folders/folders/@item?path=' + folderPath
const requestInfo: any = {
method: 'GET'
}
if (accessToken) {
requestInfo.headers = { Authorization: `Bearer ${accessToken}` }
}
const { result: folder } = await this.request<Folder>(
`${this.serverUrl}${url}`,
requestInfo
).catch((err) => {
return { result: null }
})
const { result: folder } = await this.requestClient
.get<Folder>(`${this.serverUrl}${url}`, accessToken)
.catch(() => {
return { result: null }
})
if (!folder) return undefined
return `/folders/folders/${folder.id}`
@@ -1216,20 +1144,12 @@ export class SASViyaApiClient {
private async getRecycleBinUri(accessToken: string) {
const url = '/folders/folders/@myRecycleBin'
const requestInfo = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + accessToken
}
}
const { result: folder } = await this.request<Folder>(
`${this.serverUrl}${url}`,
requestInfo
).catch((err) => {
return { result: null }
})
const { result: folder } = await this.requestClient
.get<Folder>(`${this.serverUrl}${url}`, accessToken)
.catch(() => {
return { result: null }
})
if (!folder) return undefined
@@ -1293,35 +1213,31 @@ export class SASViyaApiClient {
const sourceFolderId = sourceFolderUri?.split('/').pop()
const url = sourceFolderUri
const requestInfo = {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + accessToken
},
body: JSON.stringify({
id: sourceFolderId,
name: targetFolderName,
parentFolderUri: targetParentFolderUri
})
}
const { result: folder } = await this.requestClient
.patch<Folder>(
`${this.serverUrl}${url}`,
{
id: sourceFolderId,
name: targetFolderName,
parentFolderUri: targetParentFolderUri
},
accessToken
)
.catch((err) => {
if (err.code && err.code === 'ENOTFOUND') {
const notFoundError = {
body: {
message: `Folder '${sourceFolder
.split('/')
.pop()}' was not found.`
}
}
const { result: folder } = await this.request<Folder>(
`${this.serverUrl}${url}`,
requestInfo
).catch((err) => {
if (err.code && err.code === 'ENOTFOUND') {
const notFoundError = {
body: JSON.stringify({
message: `Folder '${sourceFolder.split('/').pop()}' was not found.`
})
throw notFoundError
}
throw notFoundError
}
throw err
})
throw err
})
if (!folder) return undefined
@@ -1349,42 +1265,4 @@ export class SASViyaApiClient {
return movedFolder
}
setCsrfTokenLocal = (csrfToken: CsrfToken) => {
this.csrfToken = csrfToken
this.setCsrfToken(csrfToken)
}
setFileUploadCsrfToken = (csrfToken: CsrfToken) => {
this.fileUploadCsrfToken = csrfToken
}
private async request<T>(
url: string,
options: RequestInit,
contentType: 'text' | 'json' = 'json',
type: 'fileUpload' | 'other' = 'other'
) {
const callback =
type === 'fileUpload'
? this.setFileUploadCsrfToken
: this.setCsrfTokenLocal
if (type === 'other') {
if (this.csrfToken) {
options.headers = {
...options.headers,
[this.csrfToken.headerName]: this.csrfToken.value
}
}
} else {
if (this.fileUploadCsrfToken) {
options.headers = {
...options.headers,
[this.fileUploadCsrfToken.headerName]: this.fileUploadCsrfToken.value
}
}
}
return await makeRequest<T>(url, options, callback, contentType)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { Session, Context, CsrfToken, SessionVariable } from './types'
import { asyncForEach, makeRequest, isUrl } from './utils'
import { asyncForEach, isUrl } from './utils'
import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from './request/RequestClient'
const MAX_SESSION_COUNT = 1
const RETRY_LIMIT: number = 3
@@ -14,14 +15,13 @@ export class SessionManager {
constructor(
private serverUrl: string,
private contextName: string,
private setCsrfToken: (csrfToken: CsrfToken) => void
private requestClient: RequestClient
) {
if (serverUrl) isUrl(serverUrl)
}
private sessions: Session[] = []
private currentContext: Context | null = null
private csrfToken: CsrfToken | null = null
private _debug: boolean = false
private printedSessionState = {
printed: false,
@@ -58,15 +58,8 @@ export class SessionManager {
}
async clearSession(id: string, accessToken?: string) {
const deleteSessionRequest = {
method: 'DELETE',
headers: this.getHeaders(accessToken)
}
return await this.request<Session>(
`${this.serverUrl}/compute/sessions/${id}`,
deleteSessionRequest
)
return await this.requestClient
.delete<Session>(`/compute/sessions/${id}`, accessToken)
.then(() => {
this.sessions = this.sessions.filter((s) => s.id !== id)
})
@@ -98,17 +91,20 @@ export class SessionManager {
}
private async createAndWaitForSession(accessToken?: string) {
const createSessionRequest = {
method: 'POST',
headers: this.getHeaders(accessToken)
}
const { result: createdSession, etag } = await this.request<Session>(
`${this.serverUrl}/compute/contexts/${this.currentContext!.id}/sessions`,
createSessionRequest
).catch((err) => {
throw err
})
const {
result: createdSession,
etag
} = await this.requestClient
.post<Session>(
`${this.serverUrl}/compute/contexts/${
this.currentContext!.id
}/sessions`,
{},
accessToken
)
.catch((err) => {
throw err
})
await this.waitForSession(createdSession, etag, accessToken)
@@ -119,13 +115,13 @@ export class SessionManager {
private async setCurrentContext(accessToken?: string) {
if (!this.currentContext) {
const { result: contexts } = await this.request<{
items: Context[]
}>(`${this.serverUrl}/compute/contexts?limit=10000`, {
headers: this.getHeaders(accessToken)
}).catch((err) => {
throw err
})
const { result: contexts } = await this.requestClient
.get<{
items: Context[]
}>(`${this.serverUrl}/compute/contexts?limit=10000`, accessToken)
.catch((err) => {
throw err
})
const contextsList =
contexts && contexts.items && contexts.items.length
@@ -166,10 +162,7 @@ export class SessionManager {
accessToken?: string
) {
let sessionState = session.state
const headers: any = {
...this.getHeaders(accessToken),
'If-None-Match': etag
}
const stateLink = session.links.find((l: any) => l.rel === 'state')
return new Promise(async (resolve, _) => {
@@ -185,12 +178,10 @@ export class SessionManager {
this.printedSessionState.printed = true
}
const { result: state } = await this.requestSessionStatus<string>(
const state = await this.getSessionState(
`${this.serverUrl}${stateLink.href}?wait=30`,
{
headers
},
'text'
etag!,
accessToken
).catch((err) => {
throw err
})
@@ -223,73 +214,33 @@ export class SessionManager {
})
}
private async request<T>(
private async getSessionState(
url: string,
options: RequestInit,
contentType: 'text' | 'json' = 'json'
etag: string,
accessToken?: string
) {
if (this.csrfToken) {
options.headers = {
...options.headers,
[this.csrfToken.headerName]: this.csrfToken.value
}
}
return await this.requestClient
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
.then((res) => res.result as string)
.catch((err) => {
if (err.status === INTERNAL_SAS_ERROR.status)
return INTERNAL_SAS_ERROR.message
return await makeRequest<T>(
url,
options,
(token) => {
this.csrfToken = token
this.setCsrfToken(token)
},
contentType
).catch((err) => {
throw err
})
}
private async requestSessionStatus<T>(
url: string,
options: RequestInit,
contentType: 'text' | 'json' = 'json'
) {
if (this.csrfToken) {
options.headers = {
...options.headers,
[this.csrfToken.headerName]: this.csrfToken.value
}
}
return await makeRequest<T>(
url,
options,
(token) => {
this.csrfToken = token
this.setCsrfToken(token)
},
contentType
).catch((err) => {
if (err.status === INTERNAL_SAS_ERROR.status)
return { result: INTERNAL_SAS_ERROR.message }
throw err
})
throw err
})
}
async getVariable(sessionId: string, variable: string, accessToken?: string) {
const getSessionVariable = {
method: 'GET',
headers: this.getHeaders(accessToken)
}
return await this.request<SessionVariable>(
`${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`,
getSessionVariable
).catch((err) => {
throw prefixMessage(
err,
`Error while fetching session variable '${variable}'.`
return await this.requestClient
.get<SessionVariable>(
`${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`,
accessToken
)
})
.catch((err) => {
throw prefixMessage(
err,
`Error while fetching session variable '${variable}'.`
)
})
}
}

7
src/__mocks__/axios.ts Normal file
View File

@@ -0,0 +1,7 @@
import { AxiosStatic } from 'axios'
const mockAxios = jest.genMockFromModule('axios') as AxiosStatic
mockAxios.create = jest.fn(() => mockAxios)
export default mockAxios

156
src/auth/AuthManager.ts Normal file
View File

@@ -0,0 +1,156 @@
import { ServerType } from '@sasjs/utils/types'
import { isAuthorizeFormRequired } from '.'
import { RequestClient } from '../request/RequestClient'
import { serialize } from '../utils'
export class AuthManager {
public userName = ''
private loginUrl: string
private logoutUrl: string
constructor(
private serverUrl: string,
private serverType: ServerType,
private requestClient: RequestClient,
private loginCallback: () => Promise<void>
) {
this.loginUrl = `/SASLogon/login`
this.logoutUrl =
this.serverType === ServerType.Sas9
? '/SASLogon/logout?'
: '/SASLogon/logout.do?'
}
/**
* Logs into the SAS server with the supplied credentials.
* @param username - a string representing the username.
* @param password - a string representing the password.
*/
public async logIn(username: string, password: string) {
const loginParams: any = {
_service: 'default',
username,
password
}
this.userName = loginParams.username
const { isLoggedIn, loginForm } = await this.checkSession()
if (isLoggedIn) {
await this.loginCallback()
return {
isLoggedIn,
userName: this.userName
}
}
for (const key in loginForm) {
loginParams[key] = loginForm[key]
}
const loginParamsStr = serialize(loginParams)
const { result: loginResponse } = await this.requestClient.post<string>(
this.loginUrl,
loginParamsStr,
undefined,
'text/plain',
{
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
}
)
let loggedIn = isLogInSuccess(loginResponse)
if (!loggedIn) {
const currentSession = await this.checkSession()
loggedIn = currentSession.isLoggedIn
}
if (loggedIn) {
this.loginCallback()
}
return {
isLoggedIn: !!loggedIn,
userName: this.userName
}
}
/**
* 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() {
const { result: loginResponse } = await this.requestClient.get<string>(
this.loginUrl.replace('.do', ''),
undefined,
'text/plain'
)
const responseText = loginResponse
const isLoggedIn = /<button.+onClick.+logout/gm.test(responseText)
let loginForm: any = null
if (!isLoggedIn) {
loginForm = await this.getLoginForm(responseText)
}
return Promise.resolve({
isLoggedIn,
userName: this.userName,
loginForm
})
}
private getLoginForm(response: any) {
const pattern: RegExp = /<form.+action="(.*Logon[^"]*).*>/
const matches = pattern.exec(response)
const formInputs: any = {}
if (matches && matches.length) {
this.setLoginUrl(matches)
const inputs = response.match(/<input.*"hidden"[^>]*>/g)
if (inputs) {
inputs.forEach((inputStr: string) => {
const valueMatch = inputStr.match(/name="([^"]*)"\svalue="([^"]*)/)
if (valueMatch && valueMatch.length) {
formInputs[valueMatch[1]] = valueMatch[2]
}
})
}
}
return Object.keys(formInputs).length ? formInputs : null
}
private setLoginUrl = (matches: RegExpExecArray) => {
let parsedURL = matches[1].replace(/\?.*/, '')
if (parsedURL[0] === '/') {
parsedURL = parsedURL.substr(1)
const tempLoginLink = this.serverUrl
? `${this.serverUrl}/${parsedURL}`
: `${parsedURL}`
const loginUrl = tempLoginLink
this.loginUrl =
this.serverType === ServerType.SasViya
? tempLoginLink
: loginUrl.replace('.do', '')
}
}
/**
* Logs out of the configured SAS server.
*/
public logOut() {
this.requestClient.clearCsrfTokens()
return this.requestClient.get(this.logoutUrl, undefined).then(() => true)
}
}
const isLogInSuccess = (response: string): boolean =>
/You have signed in/gm.test(response)

3
src/auth/index.ts Normal file
View File

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

View File

@@ -0,0 +1,217 @@
import { AuthManager } from '../AuthManager'
import * as dotenv from 'dotenv'
import { ServerType } from '@sasjs/utils/types'
import axios from 'axios'
import {
mockLoginAuthoriseRequiredResponse,
mockLoginSuccessResponse
} from './mockResponses'
import { serialize } from '../../utils'
import { RequestClient } from '../../request/RequestClient'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe('AuthManager', () => {
const authCallback = jest.fn().mockImplementation(() => Promise.resolve())
const serverUrl = 'http://test-server.com'
const serverType = ServerType.SasViya
const userName = 'test-username'
const password = 'test-password'
const requestClient = new RequestClient(serverUrl)
beforeAll(() => {
dotenv.config()
jest.restoreAllMocks()
})
it('should instantiate and set the correct URLs for a Viya server', () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
expect(authManager).toBeTruthy()
expect((authManager as any).serverUrl).toEqual(serverUrl)
expect((authManager as any).serverType).toEqual(serverType)
expect((authManager as any).loginUrl).toEqual(`/SASLogon/login`)
expect((authManager as any).logoutUrl).toEqual('/SASLogon/logout.do?')
})
it('should instantiate and set the correct URLs for a SAS9 server', () => {
const authCallback = () => Promise.resolve()
const serverType = ServerType.Sas9
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
expect(authManager).toBeTruthy()
expect((authManager as any).serverUrl).toEqual(serverUrl)
expect((authManager as any).serverType).toEqual(serverType)
expect((authManager as any).loginUrl).toEqual(`/SASLogon/login`)
expect((authManager as any).logoutUrl).toEqual('/SASLogon/logout?')
})
it('should call the auth callback and return when already logged in', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: true,
userName: 'test',
loginForm: 'test'
})
)
const loginResponse = await authManager.logIn(userName, password)
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
expect(authCallback).toHaveBeenCalledTimes(1)
done()
})
it('should post a login request to the server if not logged in', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
userName: 'test',
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: mockLoginSuccessResponse })
)
const loginResponse = await authManager.logIn(userName, password)
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
const loginParams = serialize({
_service: 'default',
username: userName,
password,
name: 'test'
})
expect(mockedAxios.post).toHaveBeenCalledWith(
`/SASLogon/login`,
loginParams,
{
withCredentials: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
}
}
)
expect(authCallback).toHaveBeenCalledTimes(1)
done()
})
it('should parse and submit the authorisation form when necessary', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest
.spyOn(requestClient, 'authorize')
.mockImplementation(() => Promise.resolve())
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
userName: 'test',
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementationOnce(() =>
Promise.resolve({
data: mockLoginAuthoriseRequiredResponse,
config: { url: 'https://test.com/SASLogon/login' },
request: { responseURL: 'https://test.com/OAuth/authorize' }
})
)
mockedAxios.get.mockImplementationOnce(() =>
Promise.resolve({
data: mockLoginAuthoriseRequiredResponse
})
)
await authManager.logIn(userName, password)
expect(requestClient.authorize).toHaveBeenCalledWith(
mockLoginAuthoriseRequiredResponse
)
done()
})
it('should check and return session information if logged in', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: '<button onClick="logout">' })
)
const response = await authManager.checkSession()
expect(response.isLoggedIn).toBeTruthy()
expect(mockedAxios.get).toHaveBeenNthCalledWith(1, `/SASLogon/login`, {
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
Accept: '*/*',
'Content-Type': 'text/plain'
}
})
done()
})
it('should check and return session information if logged in', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: '<button onClick="logout">' })
)
const response = await authManager.checkSession()
expect(response.isLoggedIn).toBeTruthy()
expect(mockedAxios.get).toHaveBeenNthCalledWith(1, `/SASLogon/login`, {
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
Accept: '*/*',
'Content-Type': 'text/plain'
}
})
done()
})
})

View File

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

View File

@@ -0,0 +1,24 @@
import { convertToCSV } from '../utils/convertToCsv'
export const generateFileUploadForm = (
formData: FormData,
data: any
): FormData => {
for (const tableName in data) {
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,31 @@
import { convertToCSV } from '../utils/convertToCsv'
import { splitChunks } from '../utils/splitChunks'
export const generateTableUploadForm = (formData: FormData, data: any) => {
const sasjsTables = []
const requestParams: any = {}
let tableCounter = 0
for (const tableName in data) {
tableCounter++
sasjsTables.push(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.'
)
}
// if csv has length more then 16k, send in chunks
if (csv.length > 16000) {
const csvChunks = splitChunks(csv)
// append chunks to form data with same key
csvChunks.map((chunk) => {
formData.append(`sasjs${tableCounter}data`, chunk)
})
} else {
requestParams[`sasjs${tableCounter}data`] = csv
}
}
requestParams['sasjs_tables'] = sasjsTables.join(' ')
return { formData, requestParams }
}

View File

@@ -0,0 +1,54 @@
import { ServerType } from '@sasjs/utils/types'
import { ErrorResponse } from '..'
import { SASViyaApiClient } from '../SASViyaApiClient'
import { ComputeJobExecutionError, LoginRequiredError } from '../types'
import { BaseJobExecutor } from './JobExecutor'
export class ComputeJobExecutor extends BaseJobExecutor {
constructor(serverUrl: string, private sasViyaApiClient: SASViyaApiClient) {
super(serverUrl, ServerType.SasViya)
}
async execute(
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any,
accessToken?: string
) {
const loginCallback = loginRequiredCallback || (() => Promise.resolve())
const waitForResult = true
const expectWebout = true
return this.sasViyaApiClient
?.executeComputeJob(
sasJob,
config.contextName,
config.debug,
data,
accessToken,
waitForResult,
expectWebout
)
.then((response) => {
this.appendRequest(response, sasJob, config.debug)
let responseJson
return response.result
return responseJson
})
.catch(async (e: Error) => {
if (e instanceof ComputeJobExecutionError) {
this.appendRequest(e, sasJob, config.debug)
}
if (e instanceof LoginRequiredError) {
await loginCallback()
this.appendWaitingRequest(() =>
this.execute(sasJob, data, config, loginRequiredCallback)
)
}
return Promise.reject(new ErrorResponse(e?.message, e))
})
}
}

View File

@@ -0,0 +1,40 @@
import { ServerType } from '@sasjs/utils/types'
import { ErrorResponse } from '..'
import { SASViyaApiClient } from '../SASViyaApiClient'
import { JobExecutionError, LoginRequiredError } from '../types'
import { BaseJobExecutor } from './JobExecutor'
export class JesJobExecutor extends BaseJobExecutor {
constructor(serverUrl: string, private sasViyaApiClient: SASViyaApiClient) {
super(serverUrl, ServerType.SasViya)
}
async execute(
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any,
accessToken?: string
) {
const loginCallback = loginRequiredCallback || (() => Promise.resolve())
return await this.sasViyaApiClient
?.executeJob(sasJob, config.contextName, config.debug, data, accessToken)
.then((response) => {
this.appendRequest(response, sasJob, config.debug)
return response.result
})
.catch(async (e: Error) => {
if (e instanceof JobExecutionError) {
this.appendRequest(e, sasJob, config.debug)
}
if (e instanceof LoginRequiredError) {
await loginCallback()
this.appendWaitingRequest(() =>
this.execute(sasJob, data, config, loginRequiredCallback)
)
}
return Promise.reject(new ErrorResponse(e?.message, e))
})
}
}

View File

@@ -0,0 +1,96 @@
import { ServerType } from '@sasjs/utils/types'
import { SASjsRequest } from '../types'
import { asyncForEach, parseGeneratedCode, parseSourceCode } from '../utils'
export type ExecuteFunction = () => Promise<any>
export interface JobExecutor {
execute: (
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any,
accessToken?: string
) => Promise<any>
resendWaitingRequests: () => Promise<void>
getRequests: () => SASjsRequest[]
clearRequests: () => void
}
export abstract class BaseJobExecutor implements JobExecutor {
constructor(protected serverUrl: string, protected serverType: ServerType) {}
private waitingRequests: ExecuteFunction[] = []
private requests: SASjsRequest[] = []
abstract execute(
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any,
accessToken?: string | undefined
): Promise<any>
resendWaitingRequests = async () => {
await asyncForEach(
this.waitingRequests,
async (waitingRequest: ExecuteFunction) => {
await waitingRequest()
}
)
this.waitingRequests = []
return
}
getRequests = () => this.requests
clearRequests = () => {
this.requests = []
}
protected appendWaitingRequest(request: ExecuteFunction) {
this.waitingRequests.push(request)
}
protected appendRequest(response: any, program: string, debug: boolean) {
let sourceCode = ''
let generatedCode = ''
let sasWork = null
if (debug) {
if (response?.result && response?.log) {
sourceCode = parseSourceCode(response.log)
generatedCode = parseGeneratedCode(response.log)
if (response.log) {
sasWork = response.log
} else {
sasWork = response.result.WORK
}
} else if (response?.result) {
sourceCode = parseSourceCode(response.result)
generatedCode = parseGeneratedCode(response.result)
sasWork = response.result.WORK
}
}
const stringifiedResult =
typeof response?.result === 'string'
? response?.result
: JSON.stringify(response?.result, null, 2)
this.requests.push({
logFile: response?.log || stringifiedResult || response,
serviceLink: program,
timestamp: new Date(),
sourceCode,
generatedCode,
SASWORK: sasWork
})
if (this.requests.length > 20) {
this.requests.splice(0, 1)
}
}
}

View File

@@ -0,0 +1,189 @@
import { ServerType } from '@sasjs/utils/types'
import { ErrorResponse, JobExecutionError, LoginRequiredError } from '..'
import { generateFileUploadForm } from '../file/generateFileUploadForm'
import { generateTableUploadForm } from '../file/generateTableUploadForm'
import { RequestClient } from '../request/RequestClient'
import { SASViyaApiClient } from '../SASViyaApiClient'
import { isRelativePath } from '../utils'
import { BaseJobExecutor } from './JobExecutor'
export class WebJobExecutor extends BaseJobExecutor {
constructor(
serverUrl: string,
serverType: ServerType,
private jobsPath: string,
private requestClient: RequestClient,
private sasViyaApiClient: SASViyaApiClient
) {
super(serverUrl, serverType)
}
async execute(
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any
) {
const loginCallback = loginRequiredCallback || (() => Promise.resolve())
const program = isRelativePath(sasJob)
? config.appLoc
? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
: sasJob
const jobUri =
config.serverType === ServerType.SasViya
? await this.getJobUri(sasJob)
: ''
const apiUrl = `${config.serverUrl}${this.jobsPath}/?${
jobUri.length > 0
? '__program=' + program + '&_job=' + jobUri
: '_program=' + program
}`
let requestParams = {
...this.getRequestParams(config)
}
let formData = new FormData()
if (data) {
const stringifiedData = JSON.stringify(data)
if (
config.serverType === ServerType.Sas9 ||
stringifiedData.length > 500000 ||
stringifiedData.includes(';')
) {
// file upload approach
try {
formData = generateFileUploadForm(formData, data)
} catch (e) {
return Promise.reject(new ErrorResponse(e?.message, e))
}
} else {
// param based approach
try {
const {
formData: newFormData,
requestParams: params
} = generateTableUploadForm(formData, data)
formData = newFormData
requestParams = { ...requestParams, ...params }
} catch (e) {
return Promise.reject(new ErrorResponse(e?.message, e))
}
}
}
for (const key in requestParams) {
if (requestParams.hasOwnProperty(key)) {
formData.append(key, requestParams[key])
}
}
return this.requestClient!.post(apiUrl, formData, undefined)
.then(async (res) => {
if (this.serverType === ServerType.SasViya && config.debug) {
const jsonResponse = await this.parseSasViyaDebugResponse(
res.result as string
)
this.appendRequest(res, sasJob, config.debug)
return jsonResponse
}
this.appendRequest(res, sasJob, config.debug)
return res.result
})
.catch(async (e: Error) => {
if (e instanceof JobExecutionError) {
this.appendRequest(e, sasJob, config.debug)
}
if (e instanceof LoginRequiredError) {
await loginCallback()
this.appendWaitingRequest(() =>
this.execute(sasJob, data, config, loginRequiredCallback)
)
}
return Promise.reject(new ErrorResponse(e?.message, e))
})
}
private parseSasViyaDebugResponse = async (response: string) => {
const iframeStart = response.split(
'<iframe style="width: 99%; height: 500px" src="'
)[1]
const jsonUrl = iframeStart ? iframeStart.split('"></iframe>')[0] : null
if (!jsonUrl) {
throw new Error('Unable to find webout file URL.')
}
return this.requestClient
.get(this.serverUrl + jsonUrl, undefined)
.then((res) => res.result)
}
private async getJobUri(sasJob: string) {
if (!this.sasViyaApiClient) return ''
let uri = ''
let folderPath
let jobName: string
if (isRelativePath(sasJob)) {
const folderPathParts = sasJob.split('/')
folderPath = folderPathParts.length > 1 ? folderPathParts[0] : ''
jobName = folderPathParts.length > 1 ? folderPathParts[1] : ''
} else {
const folderPathParts = sasJob.split('/')
jobName = folderPathParts.pop() || ''
folderPath = folderPathParts.join('/')
}
if (!jobName) {
throw new Error('Job name is empty, null or undefined.')
}
const locJobs = await this.sasViyaApiClient.getJobsInFolder(folderPath)
if (locJobs) {
const job = locJobs.find(
(el: any) => el.name === jobName && el.contentType === 'jobDefinition'
)
if (job) {
uri = job.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

@@ -0,0 +1,4 @@
export * from './ComputeJobExecutor'
export * from './JesJobExecutor'
export * from './JobExecutor'
export * from './WebJobExecutor'

View File

@@ -0,0 +1,464 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { CsrfToken, JobExecutionError } from '..'
import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
import { LoginRequiredError } from '../types'
import { AuthorizeError } from '../types/AuthorizeError'
import { NotFoundError } from '../types/NotFoundError'
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
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
clearCsrfTokens(): void
}
export class RequestClient implements HttpClient {
private csrfToken: CsrfToken = { headerName: '', value: '' }
private fileUploadCsrfToken: CsrfToken | undefined
private httpClient: AxiosInstance
constructor(private baseUrl: string, allowInsecure = false) {
const https = require('https')
if (allowInsecure && https.Agent) {
this.httpClient = axios.create({
baseURL: baseUrl,
httpsAgent: new https.Agent({
rejectUnauthorized: !allowInsecure
})
})
} else {
this.httpClient = axios.create({
baseURL: baseUrl
})
}
}
public getCsrfToken(type: 'general' | 'file' = 'general') {
return type === 'file' ? this.fileUploadCsrfToken : this.csrfToken
}
public clearCsrfTokens() {
this.csrfToken = { headerName: '', value: '' }
this.fileUploadCsrfToken = { headerName: '', value: '' }
}
public async get<T>(
url: string,
accessToken: string | undefined,
contentType: string = 'application/json',
overrideHeaders: { [key: string]: string | number } = {}
): Promise<{ result: T; etag: string }> {
const headers = {
...this.getHeaders(accessToken, contentType),
...overrideHeaders
}
const requestConfig: AxiosRequestConfig = {
headers,
responseType: contentType === 'text/plain' ? 'text' : 'json',
withCredentials: true
}
if (contentType === 'text/plain') {
requestConfig.transformResponse = undefined
}
return this.httpClient
.get<T>(url, requestConfig)
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () =>
this.get<T>(url, accessToken, contentType, overrideHeaders)
)
})
}
public post<T>(
url: string,
data: any,
accessToken: string | undefined,
contentType = 'application/json',
overrideHeaders: { [key: string]: string | number } = {}
): Promise<{ result: T; etag: string }> {
const headers = {
...this.getHeaders(accessToken, contentType),
...overrideHeaders
}
return this.httpClient
.post<T>(url, data, { headers, withCredentials: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () =>
this.post<T>(url, data, accessToken, contentType, overrideHeaders)
)
})
}
public async put<T>(
url: string,
data: any,
accessToken: string | undefined,
overrideHeaders: { [key: string]: string | number } = {}
): Promise<{ result: T; etag: string }> {
const headers = {
...this.getHeaders(accessToken, 'application/json'),
...overrideHeaders
}
return this.httpClient
.put<T>(url, data, { headers, withCredentials: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () =>
this.put<T>(url, data, accessToken, overrideHeaders)
)
})
}
public async delete<T>(
url: string,
accessToken?: string
): Promise<{ result: T; etag: string }> {
const headers = this.getHeaders(accessToken, 'application/json')
return this.httpClient
.delete<T>(url, { headers, withCredentials: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () => this.delete<T>(url, accessToken))
})
}
public async patch<T>(
url: string,
data: any = {},
accessToken?: string
): Promise<{ result: T; etag: string }> {
const headers = this.getHeaders(accessToken, 'application/json')
return this.httpClient
.patch<T>(url, data, { headers, withCredentials: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () =>
this.patch<T>(url, data, accessToken)
)
})
}
public async uploadFile(
url: string,
content: string,
accessToken?: string
): Promise<any> {
const headers = this.getHeaders(accessToken, 'application/json')
if (this.fileUploadCsrfToken?.value) {
headers[
this.fileUploadCsrfToken.headerName
] = this.fileUploadCsrfToken.value
}
try {
const response = await this.httpClient.post(url, content, { headers })
return {
result: response.data,
etag: response.headers['etag'] as string
}
} catch (e) {
const response = e.response as AxiosResponse
if (response?.status === 403 || response?.status === 449) {
this.parseAndSetFileUploadCsrfToken(response)
if (this.fileUploadCsrfToken) {
return this.uploadFile(url, content, accessToken)
}
throw e
}
throw e
}
}
public authorize = async (response: string) => {
let authUrl: string | null = null
const params: any = {}
const responseBody = response.split('<body>')[1].split('</body>')[0]
const bodyElement = document.createElement('div')
bodyElement.innerHTML = responseBody
const form = bodyElement.querySelector('#application_authorization')
authUrl = form ? this.baseUrl + form.getAttribute('action') : null
const inputs: any = form?.querySelectorAll('input')
for (const input of inputs) {
if (input.name === 'user_oauth_approval') {
input.value = 'true'
}
params[input.name] = input.value
}
const csrfTokenKey = Object.keys(params).find((k) =>
k?.toLowerCase().includes('csrf')
)
if (csrfTokenKey) {
this.csrfToken.value = params[csrfTokenKey]
this.csrfToken.headerName = this.csrfToken.headerName || 'x-csrf-token'
}
const formData = new FormData()
for (const key in params) {
if (params.hasOwnProperty(key)) {
formData.append(key, params[key])
}
}
if (!authUrl) {
throw new Error('Auth Form URL is null or undefined.')
}
return await this.httpClient
.post(authUrl, formData, {
responseType: 'text',
headers: { Accept: '*/*', 'Content-Type': 'text/plain' }
})
.then((res) => res.data)
.catch((error) => {
console.log(error)
})
}
private getHeaders = (
accessToken: string | undefined,
contentType: string
) => {
const headers: any = {}
if (contentType !== 'application/x-www-form-urlencoded') {
headers['Content-Type'] = contentType
}
if (contentType === 'application/json') {
headers.Accept = 'application/json'
} else {
headers.Accept = '*/*'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
if (this.csrfToken.headerName && this.csrfToken.value) {
headers[this.csrfToken.headerName] = this.csrfToken.value
}
return headers
}
private parseAndSetFileUploadCsrfToken = (response: AxiosResponse) => {
const token = this.parseCsrfToken(response)
if (token) {
this.fileUploadCsrfToken = token
}
}
private parseAndSetCsrfToken = (response: AxiosResponse) => {
const token = this.parseCsrfToken(response)
if (token) {
this.csrfToken = token
}
}
private parseCsrfToken = (response: AxiosResponse): CsrfToken | undefined => {
const tokenHeader = (response.headers[
'x-csrf-header'
] as string)?.toLowerCase()
if (tokenHeader) {
const token = response.headers[tokenHeader]
const csrfToken = {
headerName: tokenHeader,
value: token || ''
}
return csrfToken
}
}
private handleError = async (e: any, callback: any) => {
const response = e.response as AxiosResponse
if (e instanceof AuthorizeError) {
const res = await this.httpClient.get(e.confirmUrl, {
responseType: 'text',
headers: { 'Content-Type': 'text/plain', Accept: '*/*' }
})
if (isAuthorizeFormRequired(res?.data as string)) {
await this.authorize(res.data as string)
}
return await callback()
}
if (e instanceof LoginRequiredError) {
this.clearCsrfTokens()
}
if (response?.status === 403 || response?.status === 449) {
this.parseAndSetCsrfToken(response)
if (this.csrfToken.headerName && this.csrfToken.value) {
return await callback()
}
throw e
} else if (response?.status === 404) {
throw new NotFoundError(response.config.url!)
}
throw e
}
private async parseResponse<T>(response: AxiosResponse<any>) {
const etag = response?.headers ? response.headers['etag'] : ''
let parsedResponse
try {
if (typeof response.data === 'string') {
parsedResponse = JSON.parse(response.data)
} else {
parsedResponse = response.data
}
} catch {
try {
parsedResponse = JSON.parse(parseWeboutResponse(response.data))
} catch {
parsedResponse = response.data
}
}
return {
result: parsedResponse as T,
etag
}
}
}
const throwIfError = (response: AxiosResponse) => {
if (response.status === 401) {
throw new LoginRequiredError()
}
if (response.data?.entityID?.includes('login')) {
throw new LoginRequiredError()
}
if (
typeof response.data === 'string' &&
isAuthorizeFormRequired(response.data)
) {
throw new AuthorizeError(
'Authorization required',
response.request.responseURL
)
}
if (
typeof response.data === 'string' &&
isLogInRequired(response.data) &&
!response.config?.url?.includes('/SASLogon/login')
) {
throw new LoginRequiredError()
}
if (response.data?.auth_request) {
const authorizeRequestUrl = response.request.responseURL
throw new AuthorizeError(response.data.message, authorizeRequestUrl)
}
const error = parseError(response.data as string)
if (error) {
throw error
}
}
const parseError = (data: string) => {
try {
const responseJson = JSON.parse(data?.replace(/[\n\r]/g, ' '))
return responseJson.errorCode && responseJson.message
? new JobExecutionError(
responseJson.errorCode,
responseJson.message,
data?.replace(/[\n\r]/g, ' ')
)
: null
} catch (_) {
try {
const hasError = data?.includes('{"errorCode')
if (hasError) {
const parts = data.split('{"errorCode')
if (parts.length > 1) {
const error = '{"errorCode' + parts[1].split('"}')[0] + '"}'
const errorJson = JSON.parse(error.replace(/[\n\r]/g, ' '))
return new JobExecutionError(
errorJson.errorCode,
errorJson.message,
data?.replace(/[\n\r]/g, '\n')
)
}
return null
}
try {
const hasError = !!data?.match(/stored process not found: /i)
if (hasError) {
const parts = data.split(/stored process not found: /i)
if (parts.length > 1) {
const storedProcessPath = parts[1].split('<i>')[1].split('</i>')[0]
const message = `Stored process not found: ${storedProcessPath}`
return new JobExecutionError(404, message, '')
}
}
} catch (_) {
return null
}
} catch (_) {
return null
}
}
}

View File

@@ -1,34 +1,16 @@
import { ContextManager } from '../ContextManager'
import { RequestClient } from '../request/RequestClient'
import * as dotenv from 'dotenv'
import axios from 'axios'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe('ContextManager', () => {
let originalFetch: any
let fetchCallNumber = 0
const fakeGlobalFetch = (fakeResponses: object[]) => {
;(global as any).fetch = jest.fn().mockImplementation(() => {
const fakeResponse = fakeResponses[fetchCallNumber]
if (
fetchCallNumber !== fakeResponses.length &&
fakeResponses.length > 1
) {
if (fetchCallNumber + 1 === fakeResponses.length) fetchCallNumber = 0
else fetchCallNumber += 1
} else {
fetchCallNumber = 0
}
return Promise.resolve({
ok: true,
headers: { get: () => '' },
json: () => Promise.resolve(fakeResponse)
})
})
}
dotenv.config()
const contextManager = new ContextManager(
process.env.SERVER_URL as string,
() => {}
new RequestClient(process.env.SERVER_URL as string)
)
const defaultComputeContexts = contextManager.getDefaultComputeContexts
@@ -43,14 +25,6 @@ describe('ContextManager', () => {
Math.floor(Math.random() * defaultLauncherContexts.length)
]
beforeAll(() => {
originalFetch = (global as any).fetch
})
afterEach(() => {
;(global as any).fetch = originalFetch
})
describe('getComputeContexts', () => {
it('should fetch compute contexts', async () => {
const sampleComputeContext = {
@@ -65,7 +39,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext]
}
fakeGlobalFetch([sampleResponse])
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
await expect(contextManager.getComputeContexts()).resolves.toEqual([
sampleComputeContext
@@ -87,7 +63,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext]
}
fakeGlobalFetch([sampleResponse])
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
await expect(contextManager.getLauncherContexts()).resolves.toEqual([
sampleComputeContext
@@ -137,7 +115,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext]
}
fakeGlobalFetch([sampleResponse])
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
await expect(
contextManager.createComputeContext(
@@ -176,10 +156,13 @@ describe('ContextManager', () => {
items: [sampleNewComputeContext]
}
fakeGlobalFetch([
sampleResponseExistingComputeContexts,
sampleResponseCreatedComputeContext
])
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponseExistingComputeContexts })
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponseCreatedComputeContext })
)
await expect(
contextManager.createComputeContext(
@@ -226,10 +209,13 @@ describe('ContextManager', () => {
items: [sampleNewComputeContext]
}
fakeGlobalFetch([
sampleResponseExistingComputeContexts,
sampleResponseCreatedComputeContext
])
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponseExistingComputeContexts })
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponseCreatedComputeContext })
)
await expect(
contextManager.createComputeContext(
@@ -287,11 +273,16 @@ describe('ContextManager', () => {
items: [sampleNewComputeContext]
}
fakeGlobalFetch([
sampleResponseExistingComputeContexts,
sampleResponseCreatedLauncherContext,
sampleResponseCreatedComputeContext
])
mockedAxios.get
.mockImplementationOnce(() =>
Promise.resolve({ data: sampleResponseExistingComputeContexts })
)
.mockImplementationOnce(() =>
Promise.resolve({ data: sampleResponseCreatedLauncherContext })
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponseCreatedComputeContext })
)
await expect(
contextManager.createComputeContext(
@@ -346,7 +337,9 @@ describe('ContextManager', () => {
items: [sampleLauncherContext]
}
fakeGlobalFetch([sampleResponse])
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
await expect(
contextManager.createLauncherContext(contextName, 'Test Description')
@@ -380,10 +373,13 @@ describe('ContextManager', () => {
items: [sampleNewLauncherContext]
}
fakeGlobalFetch([
sampleResponseExistingLauncherContext,
sampleResponseCreatedLauncherContext
])
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponseExistingLauncherContext })
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponseCreatedLauncherContext })
)
await expect(
contextManager.createLauncherContext(contextName, 'Test Description')
@@ -448,7 +444,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext]
}
fakeGlobalFetch([sampleResponseGetComputeContextByName])
mockedAxios.put.mockImplementation(() =>
Promise.resolve({ data: sampleResponseGetComputeContextByName })
)
const expectedResponse = {
etag: '',
@@ -475,7 +473,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext]
}
fakeGlobalFetch([sampleResponse])
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
const user = 'testUser'
@@ -508,7 +508,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext]
}
fakeGlobalFetch([sampleResponse])
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
const fakedExecuteScript = async () => {
return Promise.resolve({ log: '' })
@@ -567,10 +569,13 @@ describe('ContextManager', () => {
items: [sampleComputeContext]
}
fakeGlobalFetch([
sampleResponseGetComputeContextByName,
sampleResponseDeletedContext
])
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponseGetComputeContextByName })
)
mockedAxios.delete.mockImplementation(() =>
Promise.resolve({ data: sampleResponseDeletedContext })
)
const expectedResponse = {
etag: '',

View File

@@ -1,5 +1,9 @@
import { FileUploader } from '../FileUploader'
import { UploadFile } from '../types'
import { RequestClient } from '../request/RequestClient'
import axios from 'axios'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
const sampleResponse = `{
"SYSUSERID": "cas",
@@ -24,39 +28,22 @@ const prepareFilesAndParams = () => {
}
describe('FileUploader', () => {
let originalFetch: any
const fileUploader = new FileUploader(
'/sample/apploc',
'https://sample.server.com',
'/jobs/path',
null,
null
new RequestClient('https://sample.server.com')
)
beforeAll(() => {
originalFetch = (global as any).fetch
})
beforeEach(() => {
;(global as any).fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
text: () => Promise.resolve(sampleResponse)
})
)
})
afterAll(() => {
;(global as any).fetch = originalFetch
})
it('should upload successfully', async (done) => {
const sasJob = 'test/upload'
const { files, params } = prepareFilesAndParams()
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
fileUploader.uploadFile(sasJob, files, params).then((res: any) => {
expect(JSON.stringify(res)).toEqual(
JSON.stringify(JSON.parse(sampleResponse))
)
expect(res).toEqual(JSON.parse(sampleResponse))
done()
})
})
@@ -83,10 +70,8 @@ describe('FileUploader', () => {
})
it('should throw an error when login is required', async (done) => {
;(global as any).fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
text: () => Promise.resolve('<form action="Logon">')
})
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: '<form action="Logon">' })
)
const sasJob = 'test'
@@ -101,35 +86,29 @@ describe('FileUploader', () => {
})
it('should throw an error when invalid JSON is returned by the server', async (done) => {
;(global as any).fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
text: () => Promise.resolve('{invalid: "json"')
})
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: '{invalid: "json"' })
)
const sasJob = 'test'
const { files, params } = prepareFilesAndParams()
fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
expect(err.error.message).toEqual(
'Error while parsing json from upload response.'
)
expect(err.error.message).toEqual('File upload request failed.')
done()
})
})
it('should throw an error when the server request fails', async (done) => {
;(global as any).fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
text: () => Promise.reject('{message: "Server error"}')
})
mockedAxios.post.mockImplementation(() =>
Promise.reject({ data: '{message: "Server error"}' })
)
const sasJob = 'test'
const { files, params } = prepareFilesAndParams()
fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
expect(err.error.message).toEqual('Upload request failed.')
expect(err.error.message).toEqual('File upload request failed.')
done()
})

View File

@@ -1,25 +1,19 @@
import { SessionManager } from '../SessionManager'
import * as dotenv from 'dotenv'
import { RequestClient } from '../request/RequestClient'
import axios from 'axios'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe('SessionManager', () => {
dotenv.config()
let originalFetch: any
const sessionManager = new SessionManager(
process.env.SERVER_URL as string,
process.env.DEFAULT_COMPUTE_CONTEXT as string,
() => {}
new RequestClient('https://sample.server.com')
)
beforeAll(() => {
originalFetch = (global as any).fetch
})
afterEach(() => {
;(global as any).fetch = originalFetch
})
describe('getVariable', () => {
it('should fetch session variable', async () => {
const sampleResponse = {
@@ -31,12 +25,8 @@ describe('SessionManager', () => {
version: 1
}
;(global as any).fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
ok: true,
headers: { get: () => '' },
json: () => Promise.resolve(sampleResponse)
})
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
const expectedResponse = { etag: '', result: sampleResponse }

View File

@@ -0,0 +1,7 @@
export class AuthorizeError extends Error {
constructor(public message: string, public confirmUrl: string) {
super(message)
this.name = 'AuthorizeError'
Object.setPrototypeOf(this, AuthorizeError.prototype)
}
}

View File

@@ -0,0 +1,9 @@
import { Job } from './Job'
export class ComputeJobExecutionError extends Error {
constructor(public job: Job, public log: string) {
super('Error: Job execution failed')
this.name = 'ComputeJobExecutionError'
Object.setPrototypeOf(this, ComputeJobExecutionError.prototype)
}
}

View File

@@ -0,0 +1,11 @@
export class JobExecutionError extends Error {
constructor(
public errorCode: number,
public errorMessage: string,
public result: string
) {
super(`Error Code ${errorCode}: ${errorMessage}`)
this.name = 'JobExecutionError'
Object.setPrototypeOf(this, JobExecutionError.prototype)
}
}

View File

@@ -0,0 +1,7 @@
export class LoginRequiredError extends Error {
constructor() {
super('Auth error: You must be logged in to access this resource')
this.name = 'LoginRequiredError'
Object.setPrototypeOf(this, LoginRequiredError.prototype)
}
}

View File

@@ -0,0 +1,7 @@
export class NotFoundError extends Error {
constructor(public url: string) {
super(`Error: Resource at ${url} was not found`)
this.name = 'NotFoundError'
Object.setPrototypeOf(this, NotFoundError.prototype)
}
}

View File

@@ -1,4 +1,4 @@
import { ServerType } from './ServerType'
import { ServerType } from '@sasjs/utils/types'
/**
* Specifies the configuration for the SASjs instance - eg where and how to
@@ -57,4 +57,10 @@ export class SASjsConfig {
* triggered using the APIs instead of the Job Execution Web Service broker.
*/
useComputeApi = false
/**
* Defaults to `false`.
* When set to `true`, the adapter will allow requests to SAS servers that use a self-signed SSL certificate.
* Changing this setting is not recommended.
*/
allowInsecureRequests = false
}

View File

@@ -1,14 +0,0 @@
/**
* Represents requests that are queued, pending a signon event.
*
*/
export interface SASjsWaitingRequest {
requestPromise: {
promise: any
resolve: any
reject: any
}
SASjob: string
data: any
config?: any
}

View File

@@ -1,8 +0,0 @@
/**
* Server type that can be `Viya` or `SAS9`.
*
*/
export enum ServerType {
SASViya = 'SASVIYA',
SAS9 = 'SAS9'
}

View File

@@ -1,15 +1,16 @@
export * from './ComputeJobExecutionError'
export * from './Context'
export * from './CsrfToken'
export * from './ErrorResponse'
export * from './Folder'
export * from './Job'
export * from './JobExecutionError'
export * from './JobDefinition'
export * from './JobResult'
export * from './Link'
export * from './LoginRequiredError'
export * from './SASjsConfig'
export * from './SASjsRequest'
export * from './SASjsWaitingRequest'
export * from './ServerType'
export * from './Session'
export * from './UploadFile'
export * from './PollOptions'

View File

@@ -1,15 +1,10 @@
export * from './asyncForEach'
export * from './compareTimestamps'
export * from './convertToCsv'
export * from './isAuthorizeFormRequired'
export * from './isLoginRequired'
export * from './isLoginSuccess'
export * from './isRelativePath'
export * from './isUri'
export * from './isUrl'
export * from './makeRequest'
export * from './needsRetry'
export * from './parseAndSubmitAuthorizeForm'
export * from './parseGeneratedCode'
export * from './parseSourceCode'
export * from './parseSasViyaLog'

View File

@@ -1,2 +0,0 @@
export const isLogInSuccess = (response: string): boolean =>
/You have signed in/gm.test(response)

View File

@@ -1,154 +0,0 @@
import { CsrfToken } from '../types'
import { needsRetry } from './needsRetry'
let retryCount: number = 0
const retryLimit: number = 5
export async function makeRequest<T>(
url: string,
request: RequestInit,
callback: (value: CsrfToken) => any,
contentType: 'text' | 'json' = 'json'
): Promise<{ result: T; etag: string | null }> {
let retryRequest: any = null
const responseTransform =
contentType === 'json'
? (res: Response) => res.json()
: (res: Response) => res.text()
let etag = null
const result = await fetch(url, request)
.then(async (response) => {
if (response.redirected && response.url.includes('SASLogon/login')) {
return Promise.reject({ status: 401 })
}
if (!response.ok) {
if (response.status === 403) {
const tokenHeader = response.headers.get('X-CSRF-HEADER')
if (tokenHeader) {
const token = response.headers.get(tokenHeader)
callback({
headerName: tokenHeader,
value: token || ''
})
retryRequest = {
...request,
headers: { ...request.headers, [tokenHeader]: token }
}
return await fetch(url, retryRequest).then((res) => {
etag = res.headers.get('ETag')
return responseTransform(res)
})
} else {
let body: any = await response.text().catch((err) => {
throw err
})
try {
body = JSON.parse(body)
body.message = `Forbidden. Check your permissions and user groups, and also the scopes granted when registering your CLIENT_ID. ${
body.message || ''
}`
body = JSON.stringify(body)
} catch (_) {}
return Promise.reject({ status: response.status, body })
}
} else {
let body: any = await response.text().catch((err) => {
throw err
})
if (needsRetry(body)) {
if (retryCount < retryLimit) {
retryCount++
let retryResponse = await makeRequest(
url,
retryRequest || request,
callback,
contentType
).catch((err) => {
throw err
})
retryCount = 0
etag = retryResponse.etag
return retryResponse.result
} else {
retryCount = 0
throw new Error('Request retry limit exceeded')
}
}
if (response.status === 401) {
try {
body = JSON.parse(body)
body.message = `Unauthorized request. Check your credentials(client, secret, access token). ${
body.message || ''
}`
body = JSON.stringify(body)
} catch (_) {}
}
return Promise.reject({ status: response.status, body })
}
} else {
if (response.status === 204) {
return Promise.resolve()
}
const responseTransformed = await responseTransform(response).catch(
(err) => {
throw err
}
)
let responseText = ''
if (typeof responseTransformed === 'string') {
responseText = responseTransformed
} else {
responseText = JSON.stringify(responseTransformed)
}
if (needsRetry(responseText)) {
if (retryCount < retryLimit) {
retryCount++
const retryResponse = await makeRequest(
url,
retryRequest || request,
callback,
contentType
).catch((err) => {
throw err
})
retryCount = 0
etag = retryResponse.etag
return retryResponse.result
} else {
retryCount = 0
throw new Error('Request retry limit exceeded')
}
}
etag = response.headers.get('ETag')
return responseTransformed
}
})
.catch((err) => {
throw err
})
return { result, etag }
}

View File

@@ -1,49 +0,0 @@
export const parseAndSubmitAuthorizeForm = async (
response: string,
serverUrl: string
) => {
let authUrl: string | null = null
const params: any = {}
const responseBody = response.split('<body>')[1].split('</body>')[0]
const bodyElement = document.createElement('div')
bodyElement.innerHTML = responseBody
const form = bodyElement.querySelector('#application_authorization')
authUrl = form ? serverUrl + form.getAttribute('action') : null
const inputs: any = form?.querySelectorAll('input')
for (const input of inputs) {
if (input.name === 'user_oauth_approval') {
input.value = 'true'
}
params[input.name] = input.value
}
const formData = new FormData()
for (const key in params) {
if (params.hasOwnProperty(key)) {
formData.append(key, params[key])
}
}
return new Promise((resolve, reject) => {
if (authUrl) {
fetch(authUrl, {
method: 'POST',
credentials: 'include',
body: formData,
referrerPolicy: 'same-origin'
})
.then((res) => res.text())
.then((res) => {
resolve(res)
})
} else {
reject('Auth form url is null')
}
})
}