1
0
mirror of https://github.com/sasjs/adapter.git synced 2026-04-18 19:53:14 +00:00
Files
adapter/src/request/RequestClient.ts
2026-04-16 10:08:33 +02:00

1019 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosRequestHeaders,
AxiosResponse
} from 'axios'
import * as https from 'https'
import { CsrfToken } from '..'
import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
import {
AuthorizeError,
LoginRequiredError,
NotFoundError,
InternalServerError,
JobExecutionError,
CertificateError
} from '../types/errors'
import { SASjsRequest, HttpClient, VerboseMode } from '../types'
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
import { prefixMessage } from '@sasjs/utils/error'
import { SAS9AuthError } from '../types/errors/SAS9AuthError'
import {
parseGeneratedCode,
parseSourceCode,
createAxiosInstance
} from '../utils'
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
import { inspect } from 'util'
export class RequestClient implements HttpClient {
private requests: SASjsRequest[] = []
private requestsLimit: number = 10
private httpInterceptor?: number
private verboseMode: VerboseMode = false
protected csrfToken: CsrfToken = { headerName: '', value: '' }
protected fileUploadCsrfToken: CsrfToken | undefined
protected httpClient!: AxiosInstance
private isRecoveringFromNetworkError = false
constructor(
protected baseUrl: string,
httpsAgentOptions?: https.AgentOptions,
requestsLimit?: number,
verboseMode?: VerboseMode
) {
this.createHttpClient(baseUrl, httpsAgentOptions)
if (requestsLimit) this.requestsLimit = requestsLimit
if (verboseMode) {
this.setVerboseMode(verboseMode)
this.enableVerboseMode()
}
}
public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) {
this.createHttpClient(baseUrl, httpsAgentOptions)
}
public saveLocalStorageToken(accessToken: string, refreshToken: string) {
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', refreshToken)
}
public getCsrfToken(type: 'general' | 'file' = 'general') {
return type === 'file' ? this.fileUploadCsrfToken : this.csrfToken
}
public clearCsrfTokens() {
this.csrfToken = { headerName: '', value: '' }
this.fileUploadCsrfToken = { headerName: '', value: '' }
}
public clearLocalStorageTokens() {
localStorage.setItem('accessToken', '')
localStorage.setItem('refreshToken', '')
}
public resetInMemoryAuthState() {
const logger = process.logger || console
const clearedCookies: string[] = []
this.clearCsrfTokens()
if (typeof localStorage !== 'undefined') {
this.clearLocalStorageTokens()
}
if (typeof document !== 'undefined') {
clearedCookies.push(...this.clearAllCookies())
}
logger.warn('[resetInMemoryAuthState] cleared', {
cookies: clearedCookies,
localStorage: typeof localStorage !== 'undefined'
})
}
private clearAllCookies(): string[] {
const cookies = document.cookie.split(';')
const cleared: string[] = []
for (const cookie of cookies) {
const name = cookie.split('=')[0].trim()
if (!name) continue
document.cookie = `${name}=; Max-Age=0; Path=/;`
cleared.push(name)
}
return cleared
}
public getBaseUrl() {
return this.httpClient.defaults.baseURL || ''
}
/**
* this method returns all requests, an array of SASjsRequest type
* @returns SASjsRequest[]
*/
public getRequests = () => this.requests
/**
* this method clears the requests array, i.e set to empty
*/
public clearRequests = () => {
this.requests = []
}
/**
* this method appends the response from sasjs request to requests array
* @param response - response from sasjs request
* @param program - name of program
* @param debug - a boolean that indicates whether debug was enabled or not
*/
public appendRequest(response: any, program: string, debug: boolean) {
let sourceCode = ''
let generatedCode = ''
let sasWork = null
if (debug) {
if (response?.log) {
sourceCode = parseSourceCode(response.log)
generatedCode = parseGeneratedCode(response.log)
if (response?.result) {
sasWork = response.result.WORK
} else {
sasWork = response.log
}
} else if (response?.result) {
// We parse only if it's a string, otherwise it would throw error
if (typeof response.result === 'string') {
sourceCode = parseSourceCode(response.result)
generatedCode = parseGeneratedCode(response.result)
}
sasWork = response.result.WORK
}
}
const stringifiedResult =
typeof response?.result === 'string'
? response?.result
: JSON.stringify(response?.result, null, 2)
this.requests.push({
logFile: response?.log || stringifiedResult || response,
serviceLink: program,
timestamp: new Date(),
sourceCode,
generatedCode,
SASWORK: sasWork
})
if (this.requests.length > this.requestsLimit) {
this.requests.splice(0, 1)
}
}
public async get<T>(
url: string,
accessToken: string | undefined,
contentType: string = 'application/json',
overrideHeaders: { [key: string]: string | number } = {},
debug: boolean = false
): Promise<{ result: T; etag: string; status: number }> {
const headers = {
...this.getHeaders(accessToken, contentType),
...overrideHeaders
}
const requestConfig: AxiosRequestConfig = {
headers,
responseType: contentType === 'text/plain' ? 'text' : 'json',
withXSRFToken: true
}
if (contentType === 'text/plain') {
requestConfig.transformResponse = undefined
}
return this.httpClient
.get<T>(url, requestConfig)
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e: any) => {
return await this.handleError(
e,
() =>
this.get<T>(url, accessToken, contentType, overrideHeaders).catch(
(err) => {
throw prefixMessage(
err,
'Error while executing handle error callback. '
)
}
),
debug
)
})
}
/**
* @param contentType Newer version of Axios is more strict so if you don't
* set the contentType to `form data` while sending a FormData object
* application/json will be used by default, axios wont treat it as FormData.
* Instead, it serializes data as JSON—resulting in a payload like
* {"sometable":{}} and we lose the multipart/form-data formatting.
*/
public async post<T>(
url: string,
data: any,
accessToken: string | undefined,
contentType = 'application/json',
overrideHeaders: { [key: string]: string | number } = {},
additionalSettings: { [key: string]: string | number } = {}
): Promise<{ result: T; etag: string }> {
const headers = {
...this.getHeaders(accessToken, contentType),
...overrideHeaders
}
return this.httpClient
.post<T>(url, data, {
headers,
withXSRFToken: true,
...additionalSettings
})
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e: any) => {
return await this.handleError(e, () =>
this.post<T>(url, data, accessToken, contentType, overrideHeaders)
)
})
}
public async put<T>(
url: string,
data: any,
accessToken: string | undefined,
overrideHeaders: { [key: string]: string | number } = {}
): Promise<{ result: T; etag: string }> {
const headers = {
...this.getHeaders(accessToken, 'application/json'),
...overrideHeaders
}
return this.httpClient
.put<T>(url, data, { headers, withXSRFToken: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e: any) => {
return await this.handleError(e, () =>
this.put<T>(url, data, accessToken, overrideHeaders)
)
})
}
public async delete<T>(
url: string,
accessToken?: string
): Promise<{ result: T; etag: string }> {
const headers = this.getHeaders(accessToken, 'application/json')
return this.httpClient
.delete<T>(url, { headers, withXSRFToken: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e: any) => {
return await this.handleError(e, () => this.delete<T>(url, accessToken))
})
}
public async patch<T>(
url: string,
data: any = {},
accessToken?: string,
overrideHeaders: { [key: string]: string | number } = {}
): Promise<{ result: T; etag: string }> {
const headers = {
...this.getHeaders(accessToken, 'application/json'),
...overrideHeaders
}
return this.httpClient
.patch<T>(url, data, { headers, withXSRFToken: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e: any) => {
return await this.handleError(e, () =>
this.patch<T>(url, data, accessToken)
)
})
}
public async uploadFile(
url: string,
content: string,
accessToken?: string
): Promise<any> {
const headers = this.getHeaders(accessToken, 'application/json')
if (this.fileUploadCsrfToken?.value) {
headers[this.fileUploadCsrfToken.headerName] =
this.fileUploadCsrfToken.value
}
try {
const response = await this.httpClient.post(url, content, {
headers,
transformRequest: (requestBody) => requestBody
})
return {
result: response.data,
etag: response.headers['etag'] as string
}
} catch (e: any) {
const response = e.response as AxiosResponse
if (response?.status === 403 || response?.status === 449) {
this.parseAndSetFileUploadCsrfToken(response)
if (this.fileUploadCsrfToken) {
return this.uploadFile(url, content, accessToken)
}
throw e
}
throw e
}
}
public authorize = async (response: string) => {
let authUrl: string | null = null
const params: any = {}
const responseBody = response.split('<body>')[1].split('</body>')[0]
const bodyElement = document.createElement('div')
bodyElement.innerHTML = responseBody
const form = bodyElement.querySelector('#application_authorization')
authUrl = form ? this.baseUrl + form.getAttribute('action') : null
const inputs: any = form?.querySelectorAll('input')
for (const input of inputs) {
if (input.name === 'user_oauth_approval') {
input.value = 'true'
}
params[input.name] = input.value
}
const csrfTokenKey = Object.keys(params).find((k) =>
k?.toLowerCase().includes('csrf')
)
const logger = process.logger || console
if (csrfTokenKey) {
this.csrfToken.value = params[csrfTokenKey]
this.csrfToken.headerName = this.csrfToken.headerName || 'x-csrf-token'
logger.warn('[authorize] CSRF from form', {
headerName: this.csrfToken.headerName
})
}
const formData = new FormData()
for (const key in params) {
if (params.hasOwnProperty(key)) {
formData.append(key, params[key])
}
}
if (!authUrl) {
throw new Error('Auth Form URL is null or undefined.')
}
logger.warn('[authorize] posting to', { authUrl })
return await this.httpClient
.post(authUrl, formData, {
responseType: 'text',
headers: { Accept: '*/*', 'Content-Type': 'text/plain' }
})
.then((res) => {
logger.warn('[authorize] success', { status: res.status })
return res.data
})
.catch((error) => {
logger.error('[authorize] failed', {
code: error?.code,
status: error?.response?.status,
message: error?.message
})
})
}
/**
* 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 handleAxiosResponse = (response: AxiosResponse) => {
const { status, config, request, data } = response
const reqHeaders = request?._header ?? 'Not provided\n'
const rawHeaders = request?.res?.rawHeaders ?? ['Not provided']
const resHeaders = this.formatHeaders(rawHeaders)
const parsedResBody = this.parseInterceptedBody(data)
process.logger?.info(`HTTP Request (first 50 lines):
${reqHeaders}${this.parseInterceptedBody(config.data)}
HTTP Response Code: ${this.prettifyString(status)}
HTTP Response (first 50 lines):
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
`)
return response
}
private handleAxiosError = (error: AxiosError) => {
// Message indicating absent value.
const noValueMessage = 'Not provided'
const { response, request, config } = error
// Fallback request object that can be safely used to form request summary.
// _header is not present in responses with status 1**
// rawHeaders are not present in responses with status 1**
let fallbackRequest = {
_header: `${noValueMessage}\n`,
res: { rawHeaders: [noValueMessage] }
}
if (request) {
fallbackRequest = {
_header:
request._header ?? request._currentRequest?._header ?? noValueMessage,
res: { rawHeaders: request.res?.rawHeaders ?? [noValueMessage] }
}
}
// Fallback response object that can be safely used to form response summary.
let fallbackResponse = response || {
status: noValueMessage,
request: fallbackRequest,
config: config || {
data: noValueMessage,
headers: {} as AxiosRequestHeaders
},
data: noValueMessage
}
const { status, request: req, data: resData } = fallbackResponse
const { _header: reqHeaders, res } = req
const resHeaders = this.formatHeaders(res.rawHeaders)
const parsedResBody = this.parseInterceptedBody(resData)
process.logger?.info(`HTTP Request (first 50 lines):
${reqHeaders}${this.parseInterceptedBody(config?.data)}
HTTP Response Code: ${this.prettifyString(status)}
HTTP Response (first 50 lines):
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
`)
return error
}
// Converts an array of strings into a single string with the following format:
// <headerName>: <headerValue>
private formatHeaders = (rawHeaders: string[]): string => {
return rawHeaders.reduce((acc, value, i) => {
if (i % 2 === 0) {
acc += `${i === 0 ? '' : '\n'}${value}`
} else {
acc += `: ${value}`
}
return acc
}, '')
}
/**
* 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.handleAxiosResponse,
errorCallBack = this.handleAxiosError
) => {
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 = (
accessToken: string | undefined,
contentType: string
) => {
const headers: any = {}
if (contentType !== 'application/x-www-form-urlencoded') {
headers['Content-Type'] = contentType
}
if (contentType === 'application/json') {
headers.Accept = 'application/json'
} else {
headers.Accept = '*/*'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
if (this.csrfToken.headerName && this.csrfToken.value) {
headers[this.csrfToken.headerName] = this.csrfToken.value
}
return headers
}
protected parseAndSetFileUploadCsrfToken = (response: AxiosResponse) => {
const token = this.parseCsrfToken(response)
if (token) {
this.fileUploadCsrfToken = token
}
}
protected parseAndSetCsrfToken = (response: AxiosResponse) => {
const token = this.parseCsrfToken(response)
const logger = process.logger || console
if (token) {
this.csrfToken = token
logger.warn('[parseAndSetCsrfToken] set', {
headerName: token.headerName,
hasValue: !!token.value
})
} else {
logger.warn('[parseAndSetCsrfToken] no token found in response')
}
}
private parseCsrfToken = (response: AxiosResponse): CsrfToken | undefined => {
const tokenHeader = (
response.headers['x-csrf-header'] as string
)?.toLowerCase()
if (tokenHeader) {
const token = response.headers[tokenHeader]
const csrfToken = {
headerName: tokenHeader,
value: token || ''
}
return csrfToken
}
}
private logHandleError(step: string, details?: Record<string, any>) {
const logger = process.logger || console
logger.warn(`[handleError] ${step}`, details || '')
}
protected handleError = async (
e: any,
callback: any,
debug: boolean = false
) => {
const response = e.response as AxiosResponse
this.logHandleError('entered', {
errorType: e?.constructor?.name,
code: e?.code,
status: response?.status,
url: e?.config?.url || response?.config?.url,
hasResponse: !!response,
isRecovering: this.isRecoveringFromNetworkError
})
if (e instanceof AuthorizeError) {
this.logHandleError('AuthorizeError — fetching confirmUrl', {
confirmUrl: e.confirmUrl
})
const res = await this.httpClient
.get(e.confirmUrl, {
responseType: 'text',
headers: { 'Content-Type': 'text/plain', Accept: '*/*' }
})
.catch((err) => {
throw prefixMessage(err, 'Error while getting error confirmUrl. ')
})
const needsAuthorize = isAuthorizeFormRequired(res?.data as string)
this.logHandleError(
'AuthorizeError — authorize form required: ' + needsAuthorize
)
if (needsAuthorize) {
await this.authorize(res.data as string).catch((err) => {
throw prefixMessage(err, 'Error while authorizing request. ')
})
}
this.logHandleError('AuthorizeError — retrying callback')
return await callback().catch((err: any) => {
this.logHandleError('AuthorizeError — callback failed', {
errorType: err?.constructor?.name,
code: err?.code,
message: err?.message
})
throw prefixMessage(
err,
'Error while executing callback in handleError. '
)
})
}
if (e instanceof LoginRequiredError) {
this.logHandleError('LoginRequiredError — clearing CSRF and re-throwing')
this.clearCsrfTokens()
throw e
}
if (e instanceof InvalidSASjsCsrfError) {
this.logHandleError('InvalidSASjsCsrfError — re-fetching CSRF cookie')
// Fetching root and creating CSRF cookie
await this.httpClient
.get('/', {
withXSRFToken: true
})
.then((response) => {
const cookie =
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/.exec(
response.data
)?.[1]
this.logHandleError(
'InvalidSASjsCsrfError — cookie found: ' + !!cookie
)
if (cookie) document.cookie = cookie
})
.catch((err) => {
throw prefixMessage(err, 'Error while re-fetching CSRF token.')
})
this.logHandleError('InvalidSASjsCsrfError — retrying callback')
return await callback().catch((err: any) => {
this.logHandleError('InvalidSASjsCsrfError — callback failed', {
errorType: err?.constructor?.name,
code: err?.code,
message: err?.message
})
throw prefixMessage(
err,
'Error while executing callback in handleError. '
)
})
}
if (response?.status === 403 || response?.status === 449) {
this.parseAndSetCsrfToken(response)
const hasToken = !!(this.csrfToken.headerName && this.csrfToken.value)
this.logHandleError('403/449 — parsed CSRF from response', {
hasToken,
headerName: this.csrfToken.headerName
})
if (hasToken) {
this.logHandleError('403/449 — retrying callback with new CSRF')
return await callback().catch((err: any) => {
this.logHandleError('403/449 — callback failed', {
errorType: err?.constructor?.name,
code: err?.code,
message: err?.message
})
throw prefixMessage(
err,
'Error while executing callback in handleError. '
)
})
}
this.logHandleError(
'403/449 — no CSRF in response, throwing original error'
)
throw e
} else if (response?.status === 404) {
throw new NotFoundError(response.config.url!)
} else if (response?.status === 502) {
if (debug) throw new InternalServerError()
else return
}
if (e.isAxiosError && e.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
throw new CertificateError(e.message)
}
if (
e.isAxiosError &&
!response &&
e.code === 'ERR_NETWORK' &&
!this.isRecoveringFromNetworkError
) {
// Opaque ERR_NETWORK usually means the server rejected stale credentials.
// Wipe in-memory auth state so the retry either succeeds
// or surfaces a clean LoginRequiredError.
this.logHandleError('ERR_NETWORK — clearing all auth state and retrying')
this.resetInMemoryAuthState()
this.isRecoveringFromNetworkError = true
try {
return await callback()
} catch (retryErr: any) {
// Retry also failed — session is dead, surface LoginRequiredError
// so the app can prompt re-authentication.
this.logHandleError(
'ERR_NETWORK — retry failed, throwing LoginRequiredError',
{
errorType: retryErr?.constructor?.name,
code: retryErr?.code,
message: retryErr?.message
}
)
throw new LoginRequiredError()
} finally {
this.isRecoveringFromNetworkError = false
}
}
this.logHandleError('unhandled — throwing as-is', {
message: e?.message,
code: e?.code
})
if (e.message) throw e
else throw prefixMessage(e, 'Error while handling error. ')
}
protected parseResponse<T>(response: AxiosResponse<any>) {
const etag = response?.headers ? response.headers['etag'] : ''
let parsedResponse
let includeSAS9Log: boolean = false
try {
if (typeof response.data === 'string') {
parsedResponse = JSON.parse(response.data)
} else {
parsedResponse = response.data
}
} catch {
try {
parsedResponse = JSON.parse(parseWeboutResponse(response.data))
} catch {
parsedResponse = response.data
}
includeSAS9Log = true
}
let responseToReturn: {
result: T
etag: any
log?: string
status: number
} = {
result: parsedResponse as T,
etag,
status: response.status
}
if (includeSAS9Log) {
responseToReturn.log = response.data
}
return responseToReturn
}
private createHttpClient(
baseUrl: string,
httpsAgentOptions?: https.AgentOptions
) {
const httpsAgent = httpsAgentOptions
? new https.Agent(httpsAgentOptions)
: undefined
this.httpClient = createAxiosInstance(baseUrl, httpsAgent)
this.httpClient.defaults.validateStatus = (status) => {
return status >= 200 && status <= 401
}
}
}
export const throwIfError = (response: AxiosResponse) => {
switch (response.status) {
case 400:
if (
typeof response.data === 'object' &&
response.data.error === 'invalid_grant'
) {
// In SASVIYA when trying to get access token, if auth code is wrong status code will be 400 so in such case we return login required error.
throw new LoginRequiredError(response.data)
}
if (
typeof response.data === 'string' &&
response.data.toLowerCase() === 'invalid csrf token!'
) {
throw new InvalidSASjsCsrfError()
}
break
case 401:
if (typeof response.data === 'object') {
throw new LoginRequiredError(response.data)
} else {
throw new LoginRequiredError()
}
}
if (response.data?.entityID?.includes('login')) {
throw new LoginRequiredError()
}
if (
typeof response.data === 'string' &&
isAuthorizeFormRequired(response.data)
) {
throw new AuthorizeError(
'Authorization required',
response.request.responseURL
)
}
if (
typeof response.data === 'string' &&
isLogInRequired(response.data) &&
!response.config?.url?.includes('/SASLogon/login')
) {
throw new LoginRequiredError()
}
if (response.data?.auth_request) {
const authorizeRequestUrl = response.request.responseURL
throw new AuthorizeError(response.data.message, authorizeRequestUrl)
}
if (response.config?.url?.includes('sasAuthError')) {
throw new SAS9AuthError()
}
const error = parseError(response.data as string)
if (error) {
throw error
}
}
const parseError = (data: string) => {
if (!data) return null
try {
const responseJson = JSON.parse(data?.replace(/[\n\r]/g, ' '))
if (responseJson.errorCode && responseJson.message) {
return new JobExecutionError(
responseJson.errorCode,
responseJson.message,
data?.replace(/[\n\r]/g, ' ')
)
}
} catch (_) {}
try {
const hasError = data?.includes('{"errorCode')
if (hasError) {
const parts = data.split('{"errorCode')
if (parts.length > 1) {
const error = '{"errorCode' + parts[1].split('"}')[0] + '"}'
const errorJson = JSON.parse(error.replace(/[\n\r]/g, ' '))
return new JobExecutionError(
errorJson.errorCode,
errorJson.message,
data?.replace(/[\n\r]/g, '\n')
)
}
}
} catch (_) {}
try {
const hasError = !!data?.match(/stored process not found: /i)
if (hasError) {
const parts = data.split(/stored process not found: /i)
if (parts.length > 1) {
const storedProcessPath = parts[1].split('<i>')[1].split('</i>')[0]
const message = storedProcessPath.endsWith('runner')
? `SASJS runner not found. Here's the link (https://cli.sasjs.io/auth/#sasjs-runner) to the SAS code for registering the SASjs runner`
: `Stored process not found: ${storedProcessPath}`
return new JobExecutionError(500, message, '')
}
}
} catch (_) {}
try {
// There are some edge cases in which the SAS mp_abort macro
// (https://core.sasjs.io/mp__abort_8sas.html) is unable to
// provide a clean exit. In this case the JSON response will
// be wrapped in >>weboutBEGIN<< and >>weboutEND<< strings.
// Therefore, if the first string exists, we won't throw an
// error just yet (the parser may yet throw one instead)
const hasError =
!data?.match(/>>weboutBEGIN<</) &&
!!data?.match(/Stored Process Error/i) &&
!!data?.match(/This request completed with errors./i)
if (hasError) {
const parts = data.split('<h2>SAS Log</h2>')
if (parts.length > 1) {
const log = parts[1].split('<pre>')[1].split('</pre>')[0]
const message = `This request completed with errors.`
return new JobExecutionError(500, message, log)
}
}
} catch (_) {}
return null
}