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

Compare commits

..

39 Commits

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

View File

@@ -4,4 +4,4 @@ updates:
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 2
open-pull-requests-limit: 1

View File

@@ -94,10 +94,10 @@ const sasJs = new SASjs({your config})
More on the config later.
### SAS Logon
All authentication from the adapter is done against SASLogon. There are two approaches that can be taken, which are configured using the `LoginMechanism` attribute of the sasJs config object (above):
All authentication from the adapter is done against SASLogon. There are two approaches that can be taken, which are configured using the `loginMechanism` attribute of the sasJs config object (above):
* `LoginMechanism:'Redirected'` - this approach enables authentication through a SASLogon window, supporting complex authentication flows (such as 2FA) and avoids the need to handle passwords in the application itself. The styling of the window can be modified using CSS.
* `LoginMechanism:'Default'` - this approach requires that the username and password are captured, and used within the `.login()` method. This can be helpful for development, or automated testing.
* `loginMechanism:'Redirected'` - this approach enables authentication through a SASLogon window, supporting complex authentication flows (such as 2FA) and avoids the need to handle passwords in the application itself. The styling of the window can be modified using CSS.
* `loginMechanism:'Default'` - this approach requires that the username and password are captured, and used within the `.login()` method. This can be helpful for development, or automated testing.
Sample code for logging in with the `Default` approach:
@@ -125,7 +125,11 @@ sasJs.request("/path/to/my/service", dataObject)
})
```
We supply the path to the SAS service, and a data object. The data object can be null (for services with no input), or can contain one or more tables in the following format:
We supply the path to the SAS service, and a data object.
If the path starts with a `/` then it should be a full path to the service. If there is no leading `/` then it is relative to the `appLoc`.
The data object can be null (for services with no input), or can contain one or more "tables" in the following format:
```javascript
let dataObject={
@@ -141,7 +145,9 @@ let dataObject={
};
```
There are optional parameters such as a config object and a callback login function.
These tables (`tablewith2cols1row` and `tablewith1col2rows`) will be created in SAS WORK after running `%webout(FETCH)` in your SAS service.
The `request()` method also has optional parameters such as a config object and a callback login function.
The response object will contain returned tables and columns. Table names are always lowercase, and column names uppercase.
@@ -231,7 +237,8 @@ run;
%webout(OBJ,a) /* Rows in table `a` are objects (easy to use) */
%webout(ARR,b) /* Rows in table `b` are arrays (compact) */
%webout(OBJ,c,fmt=N) /* Table `c` is sent unformatted (raw) */
%webout(OBJ,c,label=d) /* Rename as `d` on JS side */
%webout(OBJ,c,label=d) /* Rename table as `d` in output JSON */
%webout(OBJ,c,label=e, maxobs=10) /* send only 10 rows back */
%webout(CLOSE) /* Close the JSON and add default variables */
```
@@ -248,6 +255,8 @@ Where an entire column is made up of special missing numerics, there would be no
%webout(OBJ,a,missing=STRING,showmeta=YES)
```
The `%webout()` macro itself is just a wrapper for the [mp_jsonout](https://core.sasjs.io/mp__jsonout_8sas.html) macro.
## Configuration
Configuration on the client side involves passing an object on startup, which can also be passed with each request. Technical documentation on the SASjsConfig class is available [here](https://adapter.sasjs.io/classes/types.sasjsconfig.html). The main config items are:
@@ -256,7 +265,7 @@ Configuration on the client side involves passing an object on startup, which ca
* `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
* `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
* `debug` - if `true` then SAS Logs and extra debug information is returned.
* `LoginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
* `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
* `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used.
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.
* `requestHistoryLimit` - Request history limit. Increasing this limit may affect browser performance, especially with debug (logs) enabled. Default is 10.

4113
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -64,7 +64,7 @@
"prettier": "2.7.1",
"process": "0.11.10",
"rimraf": "3.0.2",
"semantic-release": "18.0.0",
"semantic-release": "19.0.3",
"terser-webpack-plugin": "5.3.1",
"ts-jest": "27.1.3",
"ts-loader": "9.2.6",

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"private": true,
"dependencies": {
"@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz",
"@sasjs/test-framework": "^1.4.3",
"@sasjs/test-framework": "^1.5.6",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.41",
"@types/react": "^17.0.1",
@@ -14,7 +14,7 @@
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-router-dom": "^5.2.0",
"react-scripts": "^4.0.2",
"react-scripts": "^5.0.1",
"typescript": "^4.1.3"
},
"scripts": {
@@ -43,6 +43,6 @@
]
},
"devDependencies": {
"node-sass": "^6.0.1"
"node-sass": "^7.0.1"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ export async function refreshTokensForViya(
)
.then((res) => res.result)
.catch((err) => {
throw prefixMessage(err, 'Error while refreshing tokens')
throw prefixMessage(err, 'Error while refreshing tokens: ')
})
return authResponse

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import {
getValidJson,
parseSasViyaDebugResponse,
parseWeboutResponse
parseWeboutResponse,
SASJS_LOGS_SEPARATOR
} from '../utils'
import { UploadFile } from '../types/UploadFile'
import {
@@ -99,21 +100,8 @@ export class FileUploader extends BaseJobExecutor {
? parseWeboutResponse(res.result, uploadUrl)
: res.result
break
case ServerType.Sasjs:
if (typeof res.result._webout === 'object') {
jsonResponse = res.result._webout
} else {
const webout = parseWeboutResponse(
res.result._webout,
uploadUrl
)
jsonResponse = getValidJson(webout)
}
break
}
} else if (this.serverType === ServerType.Sasjs) {
jsonResponse = getValidJson(res.result._webout)
} else {
} else if (this.serverType !== ServerType.Sasjs) {
jsonResponse =
typeof res.result === 'string'
? getValidJson(res.result)

View File

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

View File

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

View File

@@ -0,0 +1,141 @@
import * as NodeFormData from 'form-data'
import {
AuthConfig,
ExtraResponseAttributes,
ServerType
} from '@sasjs/utils/types'
import {
ErrorResponse,
JobExecutionError,
LoginRequiredError
} from '../types/errors'
import { generateFileUploadForm } from '../file/generateFileUploadForm'
import { RequestClient } from '../request/RequestClient'
import { isRelativePath, appendExtraResponseAttributes } from '../utils'
import { BaseJobExecutor } from './JobExecutor'
export class SasjsJobExecutor extends BaseJobExecutor {
constructor(
serverUrl: string,
private jobsPath: string,
private requestClient: RequestClient
) {
super(serverUrl, ServerType.Sasjs)
}
async execute(
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any,
authConfig?: AuthConfig,
extraResponseAttributes: ExtraResponseAttributes[] = []
) {
const loginCallback = loginRequiredCallback
const program =
isRelativePath(sasJob) && config.appLoc
? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
let apiUrl = `${config.serverUrl}${this.jobsPath}/?${'_program=' + program}`
let requestParams = {
...this.getRequestParams(config)
}
/**
* Use the available form data object (FormData in Browser, NodeFormData in
* Node)
*/
let formData =
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
if (data) {
// file upload approach
try {
formData = generateFileUploadForm(formData, data)
} catch (e: any) {
return Promise.reject(new ErrorResponse(e?.message, e))
}
}
for (const key in requestParams) {
if (requestParams.hasOwnProperty(key)) {
formData.append(key, requestParams[key])
}
}
/* The NodeFormData object does not set the request header - so, set it */
const contentType =
formData instanceof NodeFormData && typeof FormData === 'undefined'
? `multipart/form-data; boundary=${formData.getBoundary()}`
: undefined
const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post(
apiUrl,
formData,
authConfig?.access_token,
contentType
)
.then(async (res: any) => {
if (Object.entries(res.result).length < 1) {
throw new JobExecutionError(
0,
`No webout was returned by job ${program}. Please check the SAS log for more info.`,
res.log
)
}
this.requestClient!.appendRequest(res, sasJob, config.debug)
const responseObject = appendExtraResponseAttributes(
res,
extraResponseAttributes
)
resolve(responseObject)
})
.catch(async (e: Error) => {
if (e instanceof JobExecutionError) {
this.requestClient!.appendRequest(e, sasJob, config.debug)
reject(new ErrorResponse(e?.message, e))
}
if (e instanceof LoginRequiredError) {
if (!loginRequiredCallback) {
reject(
new ErrorResponse(
'Request is not authenticated. Make sure .env file exists with valid credentials.',
e
)
)
}
this.appendWaitingRequest(() => {
return this.execute(
sasJob,
data,
config,
loginRequiredCallback,
authConfig,
extraResponseAttributes
).then(
(res: any) => {
resolve(res)
},
(err: any) => {
reject(err)
}
)
})
if (loginCallback) await loginCallback()
} else reject(new ErrorResponse(e?.message, e))
})
})
return requestPromise
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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