1
0
mirror of https://github.com/sasjs/adapter.git synced 2026-01-03 18:50:05 +00:00

Compare commits

..

42 Commits

Author SHA1 Message Date
Allan Bowe
ffd6bc5a5c Merge pull request #836 from sasjs/issue-835
feat(auth): added multi-language support to logIn method
2024-06-21 13:40:20 +01:00
Yury
c2e64d9ba6 chore: cleanup 2024-06-21 11:32:58 +03:00
Yury
a90f699abd feat(auth): added utils to get and check login header 2024-06-21 11:24:06 +03:00
Yury
2cca192f88 chore(auth-manager): added comment 2024-06-20 17:38:56 +03:00
Yury
053b07769a feat(auth): added multi-language support to logIn method 2024-06-20 17:15:05 +03:00
Yury Shkoda
4c4511913c Merge pull request #834 from sasjs/redirectLogin-fix
fix(auth-manager): fixed redirectedLoginUrl
2024-02-19 15:59:04 +03:00
Yury
8c64c24f3c chore: left a comment 2024-02-19 15:26:26 +03:00
Yury
1f2f445002 chore: fixed SASLogon URL in AuthManager test 2024-02-19 11:06:09 +03:00
Yury
6afa056a86 chore: testing 2024-02-19 10:58:23 +03:00
Yury
fe47ca1152 fix(auth-manager): fixed redirectedLoginUrl 2024-02-19 08:50:19 +03:00
Yury Shkoda
10da691f0f Merge pull request #833 from sasjs/parent-session-state-check
fix(poll-job-state): fixed checking session state
2023-09-15 12:12:24 +03:00
Yury Shkoda
318f9694cb test: updated unit tests threshold 2023-09-15 08:58:46 +03:00
Yury Shkoda
56e6131e5c fix(poll-job-state): fixed checking session state 2023-09-15 08:51:39 +03:00
5dfee30875 Merge pull request #832 from sasjs/parent-session-state-check
feat(job-state): added session state check to doPoll func
2023-09-11 13:03:24 +02:00
Yury Shkoda
3a186bc55c feat(job-state): added session state check to doPoll func 2023-09-11 11:17:05 +03:00
Yury Shkoda
0359fcb6be Merge pull request #830 from sasjs/issue-829
fix(execute-script): fixed executing jobs on viya using compute api
2023-09-05 15:20:38 +03:00
Yury Shkoda
f2997169cb chore: renamed executeScript to executeOnComputeApi 2023-09-05 13:09:28 +03:00
Yury Shkoda
451f2dfaca fix(execute-script): fixed executing jobs on viya using compute api 2023-09-04 18:55:59 +03:00
Yury Shkoda
38e11f1771 Merge pull request #828 from sasjs/verbose-mode-issue
Fixed verbose mode for all response statuses
2023-08-17 15:20:49 +03:00
Yury Shkoda
259b6b3ff2 test(request-client): revert changed port 2023-08-17 13:44:08 +03:00
Yury Shkoda
5b2d9e675f test: set coverage threshold 2023-08-17 13:29:57 +03:00
Yury Shkoda
8dd4ab8cec fix(verbose-mode): fixed handling axios errors 2023-08-17 13:29:27 +03:00
Allan Bowe
34135b889f Merge pull request #827 from sasjs/cli-issue-1367
Bleached Verbose Mode and unit testing pollJobState
2023-08-14 18:36:20 +01:00
Yury Shkoda
62f4577b64 fix(request-client): fixed setVerboseMode method 2023-08-14 17:01:47 +03:00
Yury Shkoda
7a4feddd82 feat: added public method executeJob 2023-08-14 17:01:15 +03:00
Yury Shkoda
681abf5b3b chore(ci-cd): added sleep step before running cypress 2023-08-14 11:25:57 +03:00
Yury Shkoda
46c6d3e7f4 test(poll-job-state): covered changing polling strategies 2023-08-14 10:55:45 +03:00
Yury Shkoda
5731b0f9b1 refactor(request-client): put related types in one file 2023-08-14 10:55:09 +03:00
Yury Shkoda
f18a523087 feat(request-client): added bleached verbose mode 2023-08-14 10:50:11 +03:00
Yury Shkoda
8cbd292f13 Merge pull request #826 from sasjs/gh-action-fix
chore(gh-action): fixed access token
2023-08-09 15:24:43 +03:00
Yury Shkoda
4851f25753 chore(gh-action): fixed access token 2023-08-08 15:46:56 +03:00
Yury Shkoda
5756638dc2 Merge pull request #825 from sasjs/verboseMode
feat(request-client): made verbose mode easier to configure
2023-08-01 09:52:59 +03:00
Yury Shkoda
e511cd613c docs(verbose): fixed docs 2023-07-31 17:41:04 +03:00
Yury Shkoda
2119c81ebb feat(sasjs-config): added verbose option 2023-07-31 17:37:39 +03:00
Yury Shkoda
ea4b30d6ef feat(request-client): made verbose mode easier to configure 2023-07-31 16:34:09 +03:00
Yury Shkoda
f1e1b33571 Merge pull request #824 from sasjs/startComputeJob-issue
feat(request-client): implemented verbose mode
2023-07-31 10:51:02 +03:00
Yury Shkoda
ccb8599f00 docs(request-client): added comments 2023-07-28 11:55:52 +03:00
Yury Shkoda
5bcd17096b feat(request-client): implemented verbose mode 2023-07-27 19:29:51 +03:00
Allan Bowe
d744ee12a3 Merge pull request #823 from sasjs/@sasjs/server-response-fix
feat(sasjs-request-client): improved parseResponse method
2023-07-26 11:45:04 +01:00
Yury Shkoda
5f15226cd9 test(sasjs-request-client): removed unnecessary part of the log 2023-07-25 17:31:39 +03:00
Yury Shkoda
f31ea28b9c refactor(sasjs-request-client): used SASJS_LOGS_SEPARATOR const 2023-07-25 16:08:16 +03:00
Yury Shkoda
e315e4a619 feat(sasjs-request-client): improved parseResponse method 2023-07-25 16:01:35 +03:00
44 changed files with 1748 additions and 240 deletions

View File

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

View File

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

View File

@@ -22,8 +22,9 @@ jobs:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: npm cache: npm
- name: Check npm audit # FIXME: uncomment 'Check npm audit' step after axios version bump
run: npm audit --production --audit-level=low # - name: Check npm audit
# run: npm audit --production --audit-level=low
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
@@ -80,6 +81,10 @@ jobs:
npm run update:adapter npm run update:adapter
pm2 start --name sasjs-test npm -- start pm2 start --name sasjs-test npm -- start
- name: Sleep for 10 seconds
run: sleep 10s
shell: bash
- name: Run cypress on sasjs - name: Run cypress on sasjs
run: | run: |
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json

View File

@@ -37,7 +37,7 @@ jobs:
- name: Push generated docs - name: Push generated docs
uses: peaceiris/actions-gh-pages@v3 uses: peaceiris/actions-gh-pages@v3
with: with:
github_token: ${{ secrets.GH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: gh-pages publish_branch: gh-pages
publish_dir: ./docs publish_dir: ./docs
cname: adapter.sasjs.io cname: adapter.sasjs.io

View File

@@ -36,7 +36,7 @@ jobs:
- name: Semantic Release - name: Semantic Release
uses: cycjimmy/semantic-release-action@v3 uses: cycjimmy/semantic-release-action@v3
env: env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Send Matrix message - name: Send Matrix message

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

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

View File

@@ -153,6 +153,10 @@ The response object will contain returned tables and columns. Table names are a
The adapter will also cache the logs (if debug enabled) and even the work tables. For performance, it is best to keep debug mode off. The adapter will also cache the logs (if debug enabled) and even the work tables. For performance, it is best to keep debug mode off.
### Verbose Mode
Set `verbose` to `true` to enable verbose mode that logs a summary of every HTTP response. Verbose mode can be disabled by calling `disableVerboseMode` method or enabled by `enableVerboseMode` method. Verbose mode also supports `bleached` mode that disables extra colors in req/res summary. To enable `bleached` verbose mode, pass `verbose` equal to `bleached` while instantiating an instance of `RequestClient` or to `setVerboseMode` method. Verbose mode can also be enabled/disabled by `startComputeJob` method.
### Session Manager ### Session Manager
To execute a script on Viya a session has to be created first which is time-consuming (~15sec). That is why a Session Manager has been created which is implementing the following logic: To execute a script on Viya a session has to be created first which is time-consuming (~15sec). That is why a Session Manager has been created which is implementing the following logic:
@@ -273,6 +277,7 @@ Configuration on the client side involves passing an object on startup, which ca
* `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server). * `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
* `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode. * `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
* `debug` - if `true` then SAS Logs and extra debug information is returned. * `debug` - if `true` then SAS Logs and extra debug information is returned.
* `verbose` - optional, if `true` then a summary of every HTTP response is logged.
* `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section. * `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
* `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used. * `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used.
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`. * `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.

View File

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

12
package-lock.json generated
View File

@@ -16800,9 +16800,9 @@
"dev": true "dev": true
}, },
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.4", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -29626,9 +29626,9 @@
"dev": true "dev": true
}, },
"word-wrap": { "word-wrap": {
"version": "1.2.4", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true "dev": true
}, },
"wordwrap": { "wordwrap": {

View File

@@ -25,7 +25,7 @@ import { prefixMessage } from '@sasjs/utils/error'
import { pollJobState } from './api/viya/pollJobState' import { pollJobState } from './api/viya/pollJobState'
import { getTokens } from './auth/getTokens' import { getTokens } from './auth/getTokens'
import { uploadTables } from './api/viya/uploadTables' import { uploadTables } from './api/viya/uploadTables'
import { executeScript } from './api/viya/executeScript' import { executeOnComputeApi } from './api/viya/executeOnComputeApi'
import { getAccessTokenForViya } from './auth/getAccessTokenForViya' import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
import { refreshTokensForViya } from './auth/refreshTokensForViya' import { refreshTokensForViya } from './auth/refreshTokensForViya'
@@ -293,7 +293,7 @@ export class SASViyaApiClient {
printPid = false, printPid = false,
variables?: MacroVar variables?: MacroVar
): Promise<any> { ): Promise<any> {
return executeScript( return executeOnComputeApi(
this.requestClient, this.requestClient,
this.sessionManager, this.sessionManager,
this.rootFolderName, this.rootFolderName,

View File

@@ -4,7 +4,12 @@ import {
UploadFile, UploadFile,
EditContextInput, EditContextInput,
PollOptions, PollOptions,
LoginMechanism LoginMechanism,
VerboseMode,
ErrorResponse,
LoginOptions,
LoginResult,
ExecutionQuery
} from './types' } from './types'
import { SASViyaApiClient } from './SASViyaApiClient' import { SASViyaApiClient } from './SASViyaApiClient'
import { SAS9ApiClient } from './SAS9ApiClient' import { SAS9ApiClient } from './SAS9ApiClient'
@@ -29,8 +34,7 @@ import {
Sas9JobExecutor, Sas9JobExecutor,
FileUploader FileUploader
} from './job-execution' } from './job-execution'
import { ErrorResponse } from './types/errors' import { AxiosResponse, AxiosError } from 'axios'
import { LoginOptions, LoginResult } from './types/Login'
interface ExecuteScriptParams { interface ExecuteScriptParams {
linesOfCode: string[] linesOfCode: string[]
@@ -157,6 +161,23 @@ export default class SASjs {
} }
} }
/**
* Executes job on SASJS server.
* @param query - an object containing job path and debug level.
* @param appLoc - an application path.
* @param authConfig - an object for authentication.
* @returns a promise that resolves into job execution result and log.
*/
public async executeJob(
query: ExecutionQuery,
appLoc: string,
authConfig?: AuthConfig
) {
this.isMethodSupported('executeScript', [ServerType.Sasjs])
return await this.sasJSApiClient?.executeJob(query, appLoc, authConfig)
}
/** /**
* Gets compute contexts. * Gets compute contexts.
* @param accessToken - an access token for an authorised user. * @param accessToken - an access token for an authorised user.
@@ -854,6 +875,7 @@ export default class SASjs {
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables. * @param variables - an object that represents macro variables.
* @param verboseMode - boolean or a string equal to 'bleached' to enable verbose mode (log every HTTP response).
*/ */
public async startComputeJob( public async startComputeJob(
sasJob: string, sasJob: string,
@@ -863,7 +885,8 @@ export default class SASjs {
waitForResult?: boolean, waitForResult?: boolean,
pollOptions?: PollOptions, pollOptions?: PollOptions,
printPid = false, printPid = false,
variables?: MacroVar variables?: MacroVar,
verboseMode?: VerboseMode
) { ) {
config = { config = {
...this.sasjsConfig, ...this.sasjsConfig,
@@ -877,6 +900,11 @@ export default class SASjs {
) )
} }
if (verboseMode) {
this.requestClient?.setVerboseMode(verboseMode)
this.requestClient?.enableVerboseMode()
} else if (verboseMode === false) this.requestClient?.disableVerboseMode()
return this.sasViyaApiClient?.executeComputeJob( return this.sasViyaApiClient?.executeComputeJob(
sasJob, sasJob,
config.contextName, config.contextName,
@@ -970,7 +998,8 @@ export default class SASjs {
this.requestClient = new RequestClientClass( this.requestClient = new RequestClientClass(
this.sasjsConfig.serverUrl, this.sasjsConfig.serverUrl,
this.sasjsConfig.httpsAgentOptions, this.sasjsConfig.httpsAgentOptions,
this.sasjsConfig.requestHistoryLimit this.sasjsConfig.requestHistoryLimit,
this.sasjsConfig.verbose
) )
} else { } else {
this.requestClient.setConfig( this.requestClient.setConfig(
@@ -1134,4 +1163,31 @@ export default class SASjs {
) )
} }
} }
/**
* Enables verbose mode that will log a summary of every HTTP response.
* @param successCallBack - function that should be triggered on every HTTP response with the status 2**.
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
*/
public enableVerboseMode(
successCallBack?: (response: AxiosResponse | AxiosError) => AxiosResponse,
errorCallBack?: (response: AxiosResponse | AxiosError) => AxiosResponse
) {
this.requestClient?.enableVerboseMode(successCallBack, errorCallBack)
}
/**
* Turns off verbose mode to log every HTTP response.
*/
public disableVerboseMode() {
this.requestClient?.disableVerboseMode()
}
/**
* Sets verbose mode.
* @param verboseMode - value of the verbose mode, can be true, false or bleached(without extra colors).
*/
public setVerboseMode = (verboseMode: VerboseMode) => {
this.requestClient?.setVerboseMode(verboseMode)
}
} }

View File

@@ -1,4 +1,4 @@
import { Session, Context, SessionVariable } from './types' import { Session, Context, SessionVariable, SessionState } from './types'
import { NoSessionStateError } from './types/errors' import { NoSessionStateError } from './types/errors'
import { asyncForEach, isUrl } from './utils' import { asyncForEach, isUrl } from './utils'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
@@ -12,6 +12,7 @@ interface ApiErrorResponse {
export class SessionManager { export class SessionManager {
private loggedErrors: NoSessionStateError[] = [] private loggedErrors: NoSessionStateError[] = []
private sessionStateLinkError = 'Error while getting session state link. '
constructor( constructor(
private serverUrl: string, private serverUrl: string,
@@ -28,7 +29,7 @@ export class SessionManager {
private _debug: boolean = false private _debug: boolean = false
private printedSessionState = { private printedSessionState = {
printed: false, printed: false,
state: '' state: SessionState.NoState
} }
public get debug() { public get debug() {
@@ -265,6 +266,18 @@ export class SessionManager {
) )
}) })
// Add response etag to Session object.
createdSession.etag = etag
// Get session state link.
const stateLink = createdSession.links.find((link) => link.rel === 'state')
// Throw error if session state link is not present.
if (!stateLink) throw this.sessionStateLinkError
// Add session state link to Session object.
createdSession.stateUrl = stateLink.href
await this.waitForSession(createdSession, etag, accessToken) await this.waitForSession(createdSession, etag, accessToken)
this.sessions.push(createdSession) this.sessions.push(createdSession)
@@ -327,32 +340,30 @@ export class SessionManager {
etag: string | null, etag: string | null,
accessToken?: string accessToken?: string
): Promise<string> { ): Promise<string> {
let { state: sessionState } = session
const { stateUrl } = session
const logger = process.logger || console const logger = process.logger || console
let sessionState = session.state
const stateLink = session.links.find((l: any) => l.rel === 'state')
if ( if (
sessionState === 'pending' || sessionState === SessionState.Pending ||
sessionState === 'running' || sessionState === SessionState.Running ||
sessionState === '' sessionState === SessionState.NoState
) { ) {
if (stateLink) { if (stateUrl) {
if (this.debug && !this.printedSessionState.printed) { if (this.debug && !this.printedSessionState.printed) {
logger.info(`Polling: ${this.serverUrl + stateLink.href}`) logger.info(`Polling: ${this.serverUrl + stateUrl}`)
this.printedSessionState.printed = true this.printedSessionState.printed = true
} }
const url = `${this.serverUrl}${stateLink.href}?wait=30` const url = `${this.serverUrl}${stateUrl}?wait=30`
const { result: state, responseStatus: responseStatus } = const { result: state, responseStatus: responseStatus } =
await this.getSessionState(url, etag!, accessToken).catch((err) => { await this.getSessionState(url, etag!, accessToken).catch((err) => {
throw prefixMessage(err, 'Error while waiting for session. ') throw prefixMessage(err, 'Error while waiting for session. ')
}) })
sessionState = state.trim() sessionState = state.trim() as SessionState
if (this.debug && this.printedSessionState.state !== sessionState) { if (this.debug && this.printedSessionState.state !== sessionState) {
logger.info(`Current session state is '${sessionState}'`) logger.info(`Current session state is '${sessionState}'`)
@@ -364,7 +375,7 @@ export class SessionManager {
if (!sessionState) { if (!sessionState) {
const stateError = new NoSessionStateError( const stateError = new NoSessionStateError(
responseStatus, responseStatus,
this.serverUrl + stateLink.href, this.serverUrl + stateUrl,
session.links.find((l: any) => l.rel === 'log')?.href as string session.links.find((l: any) => l.rel === 'log')?.href as string
) )
@@ -386,7 +397,7 @@ export class SessionManager {
return sessionState return sessionState
} else { } else {
throw 'Error while getting session state link. ' throw this.sessionStateLinkError
} }
} else { } else {
this.loggedErrors = [] this.loggedErrors = []
@@ -413,7 +424,7 @@ export class SessionManager {
return await this.requestClient return await this.requestClient
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag }) .get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
.then((res) => ({ .then((res) => ({
result: res.result as string, result: res.result as SessionState,
responseStatus: res.status responseStatus: res.status
})) }))
.catch((err) => { .catch((err) => {

View File

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

View File

@@ -3,7 +3,7 @@ import { Job, PollOptions, PollStrategy } from '../..'
import { getTokens } from '../../auth/getTokens' import { getTokens } from '../../auth/getTokens'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { JobStatePollError } from '../../types/errors' import { JobStatePollError } from '../../types/errors'
import { Link, WriteStream } from '../../types' import { Link, WriteStream, SessionState, JobSessionManager } from '../../types'
import { delay, isNode } from '../../utils' import { delay, isNode } from '../../utils'
export enum JobState { export enum JobState {
@@ -37,6 +37,7 @@ export enum JobState {
* { maxPollCount: 500, pollInterval: 30000 }, // approximately ~50.5 mins (including time to get response (~300ms)) * { maxPollCount: 500, pollInterval: 30000 }, // approximately ~50.5 mins (including time to get response (~300ms))
* { maxPollCount: 3400, pollInterval: 60000 } // approximately ~3015 mins (~125 hours) (including time to get response (~300ms)) * { maxPollCount: 3400, pollInterval: 60000 } // approximately ~3015 mins (~125 hours) (including time to get response (~300ms))
* ] * ]
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state.
* @returns - a promise which resolves with a job state * @returns - a promise which resolves with a job state
*/ */
export async function pollJobState( export async function pollJobState(
@@ -44,7 +45,8 @@ export async function pollJobState(
postedJob: Job, postedJob: Job,
debug: boolean, debug: boolean,
authConfig?: AuthConfig, authConfig?: AuthConfig,
pollOptions?: PollOptions pollOptions?: PollOptions,
jobSessionManager?: JobSessionManager
): Promise<JobState> { ): Promise<JobState> {
const logger = process.logger || console const logger = process.logger || console
@@ -127,7 +129,8 @@ export async function pollJobState(
pollOptions, pollOptions,
authConfig, authConfig,
streamLog, streamLog,
logFileStream logFileStream,
jobSessionManager
) )
currentState = result.state currentState = result.state
@@ -158,7 +161,8 @@ export async function pollJobState(
defaultPollOptions, defaultPollOptions,
authConfig, authConfig,
streamLog, streamLog,
logFileStream logFileStream,
jobSessionManager
) )
currentState = result.state currentState = result.state
@@ -208,7 +212,21 @@ const needsRetry = (state: string) =>
state === JobState.Pending || state === JobState.Pending ||
state === JobState.Unavailable state === JobState.Unavailable
const doPoll = async ( /**
* Polls job state.
* @param requestClient - the pre-configured HTTP request client.
* @param postedJob - the relative or absolute path to the job.
* @param currentState - current job state.
* @param debug - sets the _debug flag in the job arguments.
* @param pollCount - current poll count.
* @param pollOptions - an object containing maxPollCount, pollInterval, streamLog and logFolderPath.
* @param authConfig - an access token, refresh token, client and secret for an authorized user.
* @param streamLog - indicates if job log should be streamed.
* @param logStream - job log stream.
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state. Session state is considered healthy if it is equal to 'running' or 'idle'.
* @returns - a promise which resolves with a job state
*/
export const doPoll = async (
requestClient: RequestClient, requestClient: RequestClient,
postedJob: Job, postedJob: Job,
currentState: JobState, currentState: JobState,
@@ -217,7 +235,8 @@ const doPoll = async (
pollOptions: PollOptions, pollOptions: PollOptions,
authConfig?: AuthConfig, authConfig?: AuthConfig,
streamLog?: boolean, streamLog?: boolean,
logStream?: WriteStream logStream?: WriteStream,
jobSessionManager?: JobSessionManager
): Promise<{ state: JobState; pollCount: number }> => { ): Promise<{ state: JobState; pollCount: number }> => {
const { maxPollCount, pollInterval } = pollOptions const { maxPollCount, pollInterval } = pollOptions
const logger = process.logger || console const logger = process.logger || console
@@ -229,6 +248,40 @@ const doPoll = async (
let startLogLine = 0 let startLogLine = 0
while (needsRetry(state) && pollCount <= maxPollCount) { while (needsRetry(state) && pollCount <= maxPollCount) {
// Check parent session state on every 10th job state poll.
if (jobSessionManager && pollCount && pollCount % 10 === 0 && authConfig) {
const { session, sessionManager } = jobSessionManager
const { stateUrl, etag, id: sessionId } = session
const { access_token } = authConfig
const { id: jobId } = postedJob
// Get session state.
const { result: sessionState, responseStatus } = await sessionManager[
'getSessionState'
](stateUrl, etag, access_token).catch((err) => {
// Handle error while getting session state.
throw new JobStatePollError(jobId, err)
})
// Checks if session state is equal to 'running' or 'idle'.
const isSessionStatesHealthy = (state: string) =>
[SessionState.Running, SessionState.Idle].includes(
state as SessionState
)
// Clear parent session and throw an error if session state is not
// 'running', 'idle' or response status is not 200.
if (!isSessionStatesHealthy(sessionState) || responseStatus !== 200) {
sessionManager.clearSession(sessionId, access_token)
const sessionError = isSessionStatesHealthy(sessionState)
? `Session response status is not 200. Session response status is ${responseStatus}.`
: `Session state of the job is not 'running' or 'idle'. Session state is '${sessionState}'`
throw new JobStatePollError(jobId, new Error(sessionError))
}
}
state = await getJobState( state = await getJobState(
requestClient, requestClient,
postedJob, postedJob,

View File

@@ -1,13 +1,13 @@
import { RequestClient } from '../../../request/RequestClient' import { RequestClient } from '../../../request/RequestClient'
import { SessionManager } from '../../../SessionManager' import { SessionManager } from '../../../SessionManager'
import { executeScript } from '../executeScript' import { executeOnComputeApi } from '../executeOnComputeApi'
import { mockSession, mockAuthConfig, mockJob } from './mockResponses' import { mockSession, mockAuthConfig, mockJob } from './mockResponses'
import * as pollJobStateModule from '../pollJobState' import * as pollJobStateModule from '../pollJobState'
import * as uploadTablesModule from '../uploadTables' import * as uploadTablesModule from '../uploadTables'
import * as getTokensModule from '../../../auth/getTokens' import * as getTokensModule from '../../../auth/getTokens'
import * as formatDataModule from '../../../utils/formatDataForRequest' import * as formatDataModule from '../../../utils/formatDataForRequest'
import * as fetchLogsModule from '../../../utils/fetchLogByChunks' import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
import { PollOptions } from '../../../types' import { PollOptions, JobSessionManager } from '../../../types'
import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors' import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors'
import { Logger, LogLevel } from '@sasjs/utils/logger' import { Logger, LogLevel } from '@sasjs/utils/logger'
@@ -25,7 +25,7 @@ describe('executeScript', () => {
}) })
it('should not try to get fresh tokens if an authConfig is not provided', async () => { it('should not try to get fresh tokens if an authConfig is not provided', async () => {
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -38,7 +38,7 @@ describe('executeScript', () => {
}) })
it('should try to get fresh tokens if an authConfig is provided', async () => { it('should try to get fresh tokens if an authConfig is provided', async () => {
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -55,7 +55,7 @@ describe('executeScript', () => {
}) })
it('should get a session from the session manager before executing', async () => { it('should get a session from the session manager before executing', async () => {
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -72,7 +72,7 @@ describe('executeScript', () => {
.spyOn(sessionManager, 'getSession') .spyOn(sessionManager, 'getSession')
.mockImplementation(() => Promise.reject('Test Error')) .mockImplementation(() => Promise.reject('Test Error'))
const error = await executeScript( const error = await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -85,7 +85,7 @@ describe('executeScript', () => {
}) })
it('should fetch the PID when printPid is true', async () => { it('should fetch the PID when printPid is true', async () => {
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -113,7 +113,7 @@ describe('executeScript', () => {
.spyOn(sessionManager, 'getVariable') .spyOn(sessionManager, 'getVariable')
.mockImplementation(() => Promise.reject('Test Error')) .mockImplementation(() => Promise.reject('Test Error'))
const error = await executeScript( const error = await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -139,7 +139,7 @@ describe('executeScript', () => {
Promise.resolve([{ tableName: 'test', file: { id: 1 } }]) Promise.resolve([{ tableName: 'test', file: { id: 1 } }])
) )
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -163,7 +163,7 @@ describe('executeScript', () => {
}) })
it('should format data as CSV when it does not contain semicolons', async () => { it('should format data as CSV when it does not contain semicolons', async () => {
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -189,7 +189,7 @@ describe('executeScript', () => {
.spyOn(formatDataModule, 'formatDataForRequest') .spyOn(formatDataModule, 'formatDataForRequest')
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' })) .mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -217,14 +217,7 @@ describe('executeScript', () => {
sasjs_tables: 'foo', sasjs_tables: 'foo',
sasjs0data: 'bar' sasjs0data: 'bar'
}, },
arguments: { version: 2
_contextName: 'test context',
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
}, },
mockAuthConfig.access_token mockAuthConfig.access_token
) )
@@ -235,7 +228,7 @@ describe('executeScript', () => {
.spyOn(formatDataModule, 'formatDataForRequest') .spyOn(formatDataModule, 'formatDataForRequest')
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' })) .mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -264,14 +257,7 @@ describe('executeScript', () => {
sasjs0data: 'bar', sasjs0data: 'bar',
_DEBUG: 131 _DEBUG: 131
}, },
arguments: { version: 2
_contextName: 'test context',
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: false,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: false
}
}, },
mockAuthConfig.access_token mockAuthConfig.access_token
) )
@@ -282,7 +268,7 @@ describe('executeScript', () => {
.spyOn(requestClient, 'post') .spyOn(requestClient, 'post')
.mockImplementation(() => Promise.reject('Test Error')) .mockImplementation(() => Promise.reject('Test Error'))
const error = await executeScript( const error = await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -302,7 +288,7 @@ describe('executeScript', () => {
}) })
it('should immediately return the session when waitForResult is false', async () => { it('should immediately return the session when waitForResult is false', async () => {
const result = await executeScript( const result = await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -322,7 +308,12 @@ describe('executeScript', () => {
}) })
it('should poll for job completion when waitForResult is true', async () => { it('should poll for job completion when waitForResult is true', async () => {
await executeScript( const jobSessionManager: JobSessionManager = {
session: mockSession,
sessionManager: sessionManager
}
await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -343,7 +334,8 @@ describe('executeScript', () => {
mockJob, mockJob,
false, false,
mockAuthConfig, mockAuthConfig,
defaultPollOptions defaultPollOptions,
jobSessionManager
) )
}) })
@@ -352,7 +344,7 @@ describe('executeScript', () => {
.spyOn(pollJobStateModule, 'pollJobState') .spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() => Promise.reject('Poll Error')) .mockImplementation(() => Promise.reject('Poll Error'))
const error = await executeScript( const error = await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -378,7 +370,7 @@ describe('executeScript', () => {
Promise.reject({ response: { data: 'err=5113,' } }) Promise.reject({ response: { data: 'err=5113,' } })
) )
const error = await executeScript( const error = await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -404,7 +396,7 @@ describe('executeScript', () => {
}) })
it('should fetch the logs for the job if debug is true and a log URL is available', async () => { it('should fetch the logs for the job if debug is true and a log URL is available', async () => {
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -429,7 +421,7 @@ describe('executeScript', () => {
}) })
it('should not fetch the logs for the job if debug is false', async () => { it('should not fetch the logs for the job if debug is false', async () => {
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -455,7 +447,7 @@ describe('executeScript', () => {
Promise.resolve(pollJobStateModule.JobState.Failed) Promise.resolve(pollJobStateModule.JobState.Failed)
) )
const error: ComputeJobExecutionError = await executeScript( const error: ComputeJobExecutionError = await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -490,7 +482,7 @@ describe('executeScript', () => {
Promise.resolve(pollJobStateModule.JobState.Error) Promise.resolve(pollJobStateModule.JobState.Error)
) )
const error: ComputeJobExecutionError = await executeScript( const error: ComputeJobExecutionError = await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -519,7 +511,7 @@ describe('executeScript', () => {
}) })
it('should fetch the result if expectWebout is true', async () => { it('should fetch the result if expectWebout is true', async () => {
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -550,7 +542,7 @@ describe('executeScript', () => {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}) })
const error = await executeScript( const error = await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -584,7 +576,7 @@ describe('executeScript', () => {
}) })
it('should clear the session after execution is complete', async () => { it('should clear the session after execution is complete', async () => {
await executeScript( await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -611,7 +603,7 @@ describe('executeScript', () => {
.spyOn(sessionManager, 'clearSession') .spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.reject('Clear Session Error')) .mockImplementation(() => Promise.reject('Clear Session Error'))
const error = await executeScript( const error = await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',

View File

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

View File

@@ -1,17 +1,25 @@
import { Logger, LogLevel } from '@sasjs/utils/logger' import { Logger, LogLevel } from '@sasjs/utils/logger'
import { RequestClient } from '../../../request/RequestClient' import { RequestClient } from '../../../request/RequestClient'
import { mockAuthConfig, mockJob } from './mockResponses' import { mockAuthConfig, mockJob } from './mockResponses'
import { pollJobState } from '../pollJobState' import { pollJobState, doPoll, JobState } from '../pollJobState'
import * as getTokensModule from '../../../auth/getTokens' import * as getTokensModule from '../../../auth/getTokens'
import * as saveLogModule from '../saveLog' import * as saveLogModule from '../saveLog'
import * as getFileStreamModule from '../getFileStream' import * as getFileStreamModule from '../getFileStream'
import * as isNodeModule from '../../../utils/isNode' import * as isNodeModule from '../../../utils/isNode'
import * as delayModule from '../../../utils/delay' import * as delayModule from '../../../utils/delay'
import { PollOptions, PollStrategy } from '../../../types' import {
PollOptions,
PollStrategy,
SessionState,
JobSessionManager
} from '../../../types'
import { WriteStream } from 'fs' import { WriteStream } from 'fs'
import { SessionManager } from '../../../SessionManager'
import { JobStatePollError } from '../../../types'
const baseUrl = 'http://localhost' const baseUrl = 'http://localhost'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)() const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
requestClient['httpClient'].defaults.baseURL = baseUrl requestClient['httpClient'].defaults.baseURL = baseUrl
const defaultStreamLog = false const defaultStreamLog = false
@@ -276,6 +284,76 @@ describe('pollJobState', () => {
expect(delays).toEqual([pollIntervals[0], ...pollIntervals]) expect(delays).toEqual([pollIntervals[0], ...pollIntervals])
}) })
it('should change default poll strategies after completing provided poll options', async () => {
const delays: number[] = []
jest.spyOn(delayModule, 'delay').mockImplementation((ms: number) => {
delays.push(ms)
return Promise.resolve()
})
const customPollOptions: PollOptions = {
maxPollCount: 0,
pollInterval: 0
}
const requests = [
{ maxPollCount: 202, pollInterval: 300 },
{ maxPollCount: 300, pollInterval: 3000 },
{ maxPollCount: 500, pollInterval: 30000 },
{ maxPollCount: 3400, pollInterval: 60000 }
]
// ~200 requests with delay 300ms
let request = requests.splice(0, 1)[0]
let { maxPollCount, pollInterval } = request
// should be only one interval because maxPollCount is equal to 0
const pollIntervals = [customPollOptions.pollInterval]
pollIntervals.push(...Array(maxPollCount - 2).fill(pollInterval))
// ~300 requests with delay 3000
request = requests.splice(0, 1)[0]
let newAmount = request.maxPollCount
pollInterval = request.pollInterval
pollIntervals.push(...Array(newAmount - maxPollCount).fill(pollInterval))
pollIntervals.push(...Array(2).fill(pollInterval))
// ~500 requests with delay 30000
request = requests.splice(0, 1)[0]
let oldAmount = newAmount
newAmount = request.maxPollCount
pollInterval = request.pollInterval
pollIntervals.push(...Array(newAmount - oldAmount - 2).fill(pollInterval))
pollIntervals.push(...Array(2).fill(pollInterval))
// ~3400 requests with delay 60000
request = requests.splice(0, 1)[0]
oldAmount = newAmount
newAmount = request.maxPollCount
pollInterval = request.pollInterval
mockSimplePoll(newAmount)
pollIntervals.push(...Array(newAmount - oldAmount - 2).fill(pollInterval))
await pollJobState(
requestClient,
mockJob,
false,
undefined,
customPollOptions
)
expect(delays).toEqual(pollIntervals)
})
it('should throw an error if not valid poll strategies provided', async () => { it('should throw an error if not valid poll strategies provided', async () => {
// INFO: 'maxPollCount' has to be > 0 // INFO: 'maxPollCount' has to be > 0
let invalidPollStrategy = { let invalidPollStrategy = {
@@ -353,6 +431,218 @@ describe('pollJobState', () => {
}) })
}) })
describe('doPoll', () => {
const sessionStateLink = '/compute/sessions/session-id-ses0000/state'
const jobSessionManager: JobSessionManager = {
sessionManager,
session: {
id: ['id', new Date().getTime(), Math.random()].join('-'),
state: SessionState.NoState,
links: [
{
href: sessionStateLink,
method: 'GET',
rel: 'state',
type: 'text/plain',
uri: sessionStateLink
}
],
attributes: {
sessionInactiveTimeout: 900
},
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`,
stateUrl: '',
etag: ''
}
}
beforeEach(() => {
setupMocks()
})
it('should check session state on every 10th job state poll', async () => {
const mockedGetSessionState = jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.resolve({
result: SessionState.Idle,
responseStatus: 200
})
})
let getSessionStateCount = 0
jest.spyOn(requestClient, 'get').mockImplementation(() => {
getSessionStateCount++
return Promise.resolve({
result:
getSessionStateCount < 20 ? JobState.Running : JobState.Completed,
etag: 'etag-string',
status: 200
})
})
await doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
expect(mockedGetSessionState).toHaveBeenCalledTimes(2)
})
it('should handle error while checking session state', async () => {
const sessionStateError = 'Error while getting session state.'
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.reject(sessionStateError)
})
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: JobState.Running,
etag: 'etag-string',
status: 200
})
})
await expect(
doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
).rejects.toEqual(
new JobStatePollError(mockJob.id, new Error(sessionStateError))
)
})
it('should throw an error if session state is not healthy', async () => {
const filteredSessionStates = Object.values(SessionState).filter(
(state) => state !== SessionState.Running && state !== SessionState.Idle
)
const randomSessionState =
filteredSessionStates[
Math.floor(Math.random() * filteredSessionStates.length)
]
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.resolve({
result: randomSessionState,
responseStatus: 200
})
})
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: JobState.Running,
etag: 'etag-string',
status: 200
})
})
const mockedClearSession = jest
.spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.resolve())
await expect(
doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
).rejects.toEqual(
new JobStatePollError(
mockJob.id,
new Error(
`Session state of the job is not 'running' or 'idle'. Session state is '${randomSessionState}'`
)
)
)
expect(mockedClearSession).toHaveBeenCalledWith(
jobSessionManager.session.id,
mockAuthConfig.access_token
)
})
it('should handle throw an error if response status of session state is not 200', async () => {
const sessionStateResponseStatus = 500
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.resolve({
result: SessionState.Running,
responseStatus: sessionStateResponseStatus
})
})
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: JobState.Running,
etag: 'etag-string',
status: 200
})
})
const mockedClearSession = jest
.spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.resolve())
await expect(
doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
).rejects.toEqual(
new JobStatePollError(
mockJob.id,
new Error(
`Session response status is not 200. Session response status is ${sessionStateResponseStatus}.`
)
)
)
expect(mockedClearSession).toHaveBeenCalledWith(
jobSessionManager.session.id,
mockAuthConfig.access_token
)
})
})
const setupMocks = () => { const setupMocks = () => {
jest.restoreAllMocks() jest.restoreAllMocks()
jest.mock('../../../request/RequestClient') jest.mock('../../../request/RequestClient')

View File

@@ -7,6 +7,7 @@ import { extractUserLongNameSas9 } from '../utils/sas9/extractUserLongNameSas9'
import { openWebPage } from './openWebPage' import { openWebPage } from './openWebPage'
import { verifySas9Login } from './verifySas9Login' import { verifySas9Login } from './verifySas9Login'
import { verifySasViyaLogin } from './verifySasViyaLogin' import { verifySasViyaLogin } from './verifySasViyaLogin'
import { isLogInSuccessHeaderPresent } from './'
export class AuthManager { export class AuthManager {
public userName = '' public userName = ''
@@ -14,6 +15,7 @@ export class AuthManager {
private loginUrl: string private loginUrl: string
private logoutUrl: string private logoutUrl: string
private redirectedLoginUrl = `/SASLogon` //SAS 9 M8 no longer redirects from `/SASLogon/home` to the login page. `/SASLogon` seems to be stable enough across SAS versions private redirectedLoginUrl = `/SASLogon` //SAS 9 M8 no longer redirects from `/SASLogon/home` to the login page. `/SASLogon` seems to be stable enough across SAS versions
constructor( constructor(
private serverUrl: string, private serverUrl: string,
private serverType: ServerType, private serverType: ServerType,
@@ -27,6 +29,8 @@ export class AuthManager {
: this.serverType === ServerType.SasViya : this.serverType === ServerType.SasViya
? '/SASLogon/logout.do?' ? '/SASLogon/logout.do?'
: '/SASLogon/logout' : '/SASLogon/logout'
this.redirectedLoginUrl = this.serverUrl + this.redirectedLoginUrl
} }
/** /**
@@ -129,7 +133,7 @@ export class AuthManager {
let loginResponse = await this.sendLoginRequest(loginForm, loginParams) let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
let isLoggedIn = isLogInSuccess(this.serverType, loginResponse) let isLoggedIn = isLogInSuccessHeaderPresent(this.serverType, loginResponse)
if (!isLoggedIn) { if (!isLoggedIn) {
if (isCredentialsVerifyError(loginResponse)) { if (isCredentialsVerifyError(loginResponse)) {
@@ -214,7 +218,7 @@ export class AuthManager {
* - a boolean `isLoggedIn` * - a boolean `isLoggedIn`
* - a string `userName`, * - a string `userName`,
* - a string `userFullName` and * - a string `userFullName` and
* - a form `loginForm` if not loggedin. * - a form `loginForm` if not loggedIn.
*/ */
public async checkSession(): Promise<LoginResultInternal> { public async checkSession(): Promise<LoginResultInternal> {
const { isLoggedIn, userName, userLongName } = await this.fetchUserName() const { isLoggedIn, userName, userLongName } = await this.fetchUserName()
@@ -381,9 +385,3 @@ const isCredentialsVerifyError = (response: string): boolean =>
/An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test( /An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test(
response response
) )
const isLogInSuccess = (serverType: ServerType, response: any): boolean => {
if (serverType === ServerType.Sasjs) return response?.loggedin
return /You have signed in/gm.test(response)
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
*/ */
import { verifySas9Login } from '../verifySas9Login' import { verifySas9Login } from '../verifySas9Login'
import * as delayModule from '../../utils/delay' import * as delayModule from '../../utils/delay'
import { getExpectedLogInSuccessHeader } from '../'
describe('verifySas9Login', () => { describe('verifySas9Login', () => {
const serverUrl = 'http://test-server.com' const serverUrl = 'http://test-server.com'
@@ -18,7 +19,9 @@ describe('verifySas9Login', () => {
const popup = { const popup = {
window: { window: {
location: { href: serverUrl + `/SASLogon` }, location: { href: serverUrl + `/SASLogon` },
document: { body: { innerText: '<h3>You have signed in.</h3>' } } document: {
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
}
} }
} as unknown as Window } as unknown as Window

View File

@@ -3,6 +3,7 @@
*/ */
import { verifySasViyaLogin } from '../verifySasViyaLogin' import { verifySasViyaLogin } from '../verifySasViyaLogin'
import * as delayModule from '../../utils/delay' import * as delayModule from '../../utils/delay'
import { getExpectedLogInSuccessHeader } from '../'
describe('verifySasViyaLogin', () => { describe('verifySasViyaLogin', () => {
const serverUrl = 'http://test-server.com' const serverUrl = 'http://test-server.com'
@@ -19,7 +20,9 @@ describe('verifySasViyaLogin', () => {
const popup = { const popup = {
window: { window: {
location: { href: serverUrl + `/SASLogon` }, location: { href: serverUrl + `/SASLogon` },
document: { body: { innerText: '<h3>You have signed in.</h3>' } } document: {
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
}
} }
} as unknown as Window } as unknown as Window

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import { import {
getValidJson, getValidJson,
parseSasViyaDebugResponse, parseSasViyaDebugResponse,
parseWeboutResponse, parseWeboutResponse
SASJS_LOGS_SEPARATOR
} from '../utils' } from '../utils'
import { UploadFile } from '../types/UploadFile' import { UploadFile } from '../types/UploadFile'
import { import {

View File

@@ -233,7 +233,8 @@ export default class SASjs {
this.requestClient = new RequestClient( this.requestClient = new RequestClient(
this.sasjsConfig.serverUrl, this.sasjsConfig.serverUrl,
this.sasjsConfig.httpsAgentOptions, this.sasjsConfig.httpsAgentOptions,
this.sasjsConfig.requestHistoryLimit this.sasjsConfig.requestHistoryLimit,
this.sasjsConfig.verbose
) )
} else { } else {
this.requestClient.setConfig( this.requestClient.setConfig(

View File

@@ -11,7 +11,6 @@ import {
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { import {
isRelativePath, isRelativePath,
parseSasViyaDebugResponse,
appendExtraResponseAttributes, appendExtraResponseAttributes,
convertToCSV convertToCSV
} from '../../utils' } from '../../utils'

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { RequestClient } from '../request/RequestClient'
import * as dotenv from 'dotenv' import * as dotenv from 'dotenv'
import axios from 'axios' import axios from 'axios'
import { Logger, LogLevel } from '@sasjs/utils/logger' import { Logger, LogLevel } from '@sasjs/utils/logger'
import { Session, Context } from '../types' import { Session, SessionState, Context } from '../types'
jest.mock('axios') jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios> const mockedAxios = axios as jest.Mocked<typeof axios>
@@ -11,21 +11,34 @@ const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
describe('SessionManager', () => { describe('SessionManager', () => {
dotenv.config() dotenv.config()
process.env.SERVER_URL = 'https://server.com'
const sessionManager = new SessionManager( const sessionManager = new SessionManager(
process.env.SERVER_URL as string, process.env.SERVER_URL as string,
process.env.DEFAULT_COMPUTE_CONTEXT as string, process.env.DEFAULT_COMPUTE_CONTEXT as string,
requestClient requestClient
) )
const sessionStateLink = '/compute/sessions/session-id-ses0000/state'
const sessionEtag = 'etag-string'
const getMockSession = () => ({ const getMockSession = (): Session => ({
id: ['id', new Date().getTime(), Math.random()].join('-'), id: ['id', new Date().getTime(), Math.random()].join('-'),
state: '', state: SessionState.NoState,
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }], links: [
{
href: sessionStateLink,
method: 'GET',
rel: 'state',
type: 'text/plain',
uri: sessionStateLink
}
],
attributes: { attributes: {
sessionInactiveTimeout: 900 sessionInactiveTimeout: 900
}, },
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}` creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`,
stateUrl: sessionStateLink,
etag: sessionEtag
}) })
afterEach(() => { afterEach(() => {
@@ -89,19 +102,21 @@ describe('SessionManager', () => {
describe('waitForSession', () => { describe('waitForSession', () => {
const session: Session = { const session: Session = {
id: 'id', id: 'id',
state: '', state: SessionState.NoState,
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }], links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
attributes: { attributes: {
sessionInactiveTimeout: 0 sessionInactiveTimeout: 0
}, },
creationTimeStamp: '' creationTimeStamp: '',
stateUrl: sessionStateLink,
etag: sessionEtag
} }
beforeEach(() => { beforeEach(() => {
;(process as any).logger = new Logger(LogLevel.Off) ;(process as any).logger = new Logger(LogLevel.Off)
}) })
it('should reject with NoSessionStateError if SAS server did not provide session state', async () => { it('should log http response code and session state if SAS server did not provide session state', async () => {
let requestAttempt = 0 let requestAttempt = 0
const requestAttemptLimit = 10 const requestAttemptLimit = 10
const sessionState = 'idle' const sessionState = 'idle'
@@ -124,15 +139,17 @@ describe('SessionManager', () => {
sessionManager['waitForSession'](session, null, 'access_token') sessionManager['waitForSession'](session, null, 'access_token')
).resolves.toEqual(sessionState) ).resolves.toEqual(sessionState)
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit) expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit)
expect((process as any).logger.info).toHaveBeenCalledTimes(3) expect((process as any).logger.info).toHaveBeenCalledTimes(3)
expect((process as any).logger.info).toHaveBeenNthCalledWith( expect((process as any).logger.info).toHaveBeenNthCalledWith(
1, 1,
`Polling: ${process.env.SERVER_URL}` `Polling: ${sessionStateUrl}`
) )
expect((process as any).logger.info).toHaveBeenNthCalledWith( expect((process as any).logger.info).toHaveBeenNthCalledWith(
2, 2,
`Could not get session state. Server responded with 304 whilst checking state: ${process.env.SERVER_URL}` `Could not get session state. Server responded with 304 whilst checking state: ${sessionStateUrl}`
) )
expect((process as any).logger.info).toHaveBeenNthCalledWith( expect((process as any).logger.info).toHaveBeenNthCalledWith(
3, 3,
@@ -142,7 +159,7 @@ describe('SessionManager', () => {
it('should throw an error if there is no session link', async () => { it('should throw an error if there is no session link', async () => {
const customSession = JSON.parse(JSON.stringify(session)) const customSession = JSON.parse(JSON.stringify(session))
customSession.links = [] customSession.stateUrl = ''
mockedAxios.get.mockImplementation(() => mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: customSession.state, status: 200 }) Promise.resolve({ data: customSession.state, status: 200 })
@@ -156,6 +173,7 @@ describe('SessionManager', () => {
it('should throw an error if could not get session state', async () => { it('should throw an error if could not get session state', async () => {
const gettingSessionStatus = 500 const gettingSessionStatus = 500
const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}` const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}`
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
mockedAxios.get.mockImplementation(() => mockedAxios.get.mockImplementation(() =>
Promise.reject({ Promise.reject({
@@ -168,7 +186,7 @@ describe('SessionManager', () => {
}) })
) )
const expectedError = `Error while waiting for session. Error while getting session state. GET request to ${process.env.SERVER_URL}?wait=30 failed with status code ${gettingSessionStatus}. ${sessionStatusError}` const expectedError = `Error while waiting for session. Error while getting session state. GET request to ${sessionStateUrl}?wait=30 failed with status code ${gettingSessionStatus}. ${sessionStatusError}`
await expect( await expect(
sessionManager['waitForSession'](session, null, 'access_token') sessionManager['waitForSession'](session, null, 'access_token')
@@ -427,4 +445,45 @@ describe('SessionManager', () => {
) )
}) })
}) })
describe('createAndWaitForSession', () => {
it('should create session with etag and stateUrl', async () => {
const etag = sessionEtag
const customSession: any = getMockSession()
delete customSession.etag
delete customSession.stateUrl
jest.spyOn(requestClient, 'post').mockImplementation(() =>
Promise.resolve({
result: customSession,
etag
})
)
jest
.spyOn(sessionManager as any, 'setCurrentContext')
.mockImplementation(() => Promise.resolve())
sessionManager['currentContext'] = {
name: 'context name',
id: 'string',
createdBy: 'string',
version: 1
}
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() =>
Promise.resolve({ result: SessionState.Idle, responseStatus: 200 })
)
const expectedSession = await sessionManager['createAndWaitForSession']()
expect(customSession.id).toEqual(expectedSession.id)
expect(
customSession.links.find((l: any) => l.rel === 'state').href
).toEqual(expectedSession.stateUrl)
expect(expectedSession.etag).toEqual(etag)
})
})
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
interface IEnavigator {
userLanguage?: string
}
/**
* Provides preferred language of the user.
* @returns A string representing the preferred language of the user, usually the language of the browser UI. Examples of valid language codes include "en", "en-US", "fr", "fr-FR", "es-ES". More info available https://datatracker.ietf.org/doc/html/rfc5646
*/
export const getUserLanguage = () =>
window.navigator.language || (window.navigator as IEnavigator).userLanguage

View File

@@ -2,7 +2,6 @@ export * from './appendExtraResponseAttributes'
export * from './asyncForEach' export * from './asyncForEach'
export * from './compareTimestamps' export * from './compareTimestamps'
export * from './convertToCsv' export * from './convertToCsv'
export * from './constants'
export * from './createAxiosInstance' export * from './createAxiosInstance'
export * from './delay' export * from './delay'
export * from './fetchLogByChunks' export * from './fetchLogByChunks'
@@ -21,3 +20,4 @@ export * from './serialize'
export * from './splitChunks' export * from './splitChunks'
export * from './validateInput' export * from './validateInput'
export * from './getFormData' export * from './getFormData'
export * from './getUserLanguage'