1
0
mirror of https://github.com/sasjs/adapter.git synced 2025-12-11 09:24:35 +00:00
Files
adapter/src/request/RequestClient.ts
2023-01-30 23:50:40 +05:00

725 lines
20 KiB
TypeScript

import { AxiosInstance, AxiosRequestConfig, 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 } 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'
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 {
private requests: SASjsRequest[] = []
private requestsLimit: number = 10
protected csrfToken: CsrfToken = { headerName: '', value: '' }
protected fileUploadCsrfToken: CsrfToken | undefined
protected httpClient!: AxiosInstance
constructor(
protected baseUrl: string,
httpsAgentOptions?: https.AgentOptions,
requestsLimit?: number
) {
this.createHttpClient(baseUrl, httpsAgentOptions)
if (requestsLimit) this.requestsLimit = requestsLimit
}
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 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',
withCredentials: true
}
if (contentType === 'text/plain') {
requestConfig.transformResponse = undefined
}
return this.httpClient
.get<T>(url, requestConfig)
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e: 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
)
})
}
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,
withCredentials: 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, withCredentials: 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, withCredentials: 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
): Promise<{ result: T; etag: string }> {
const headers = this.getHeaders(accessToken, 'application/json')
return this.httpClient
.patch<T>(url, data, { headers, withCredentials: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e: 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')
)
if (csrfTokenKey) {
this.csrfToken.value = params[csrfTokenKey]
this.csrfToken.headerName = this.csrfToken.headerName || 'x-csrf-token'
}
const formData = new FormData()
for (const key in params) {
if (params.hasOwnProperty(key)) {
formData.append(key, params[key])
}
}
if (!authUrl) {
throw new Error('Auth Form URL is null or undefined.')
}
return await this.httpClient
.post(authUrl, formData, {
responseType: 'text',
headers: { Accept: '*/*', 'Content-Type': 'text/plain' }
})
.then((res) => res.data)
.catch((error) => {
const logger = process.logger || console
logger.error(error)
})
}
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)
if (token) {
this.csrfToken = token
}
}
private parseCsrfToken = (response: AxiosResponse): CsrfToken | undefined => {
const tokenHeader = (
response.headers['x-csrf-header'] as string
)?.toLowerCase()
if (tokenHeader) {
const token = response.headers[tokenHeader]
const csrfToken = {
headerName: tokenHeader,
value: token || ''
}
return csrfToken
}
}
protected handleError = async (
e: any,
callback: any,
debug: boolean = false
) => {
const response = e.response as AxiosResponse
if (e instanceof AuthorizeError) {
const res = await this.httpClient
.get(e.confirmUrl, {
responseType: 'text',
headers: { 'Content-Type': 'text/plain', Accept: '*/*' }
})
.catch((err) => {
throw prefixMessage(err, 'Error while getting error confirmUrl. ')
})
if (isAuthorizeFormRequired(res?.data as string)) {
await this.authorize(res.data as string).catch((err) => {
throw prefixMessage(err, 'Error while authorizing request. ')
})
}
return await callback().catch((err: any) => {
throw prefixMessage(
err,
'Error while executing callback in handleError. '
)
})
}
if (e instanceof LoginRequiredError) {
this.clearCsrfTokens()
throw e
}
if (e instanceof InvalidSASjsCsrfError) {
// Fetching root and creating CSRF cookie
await this.httpClient
.get('/', {
withCredentials: true
})
.then((response) => {
const cookie =
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/.exec(
response.data
)?.[1]
if (cookie) document.cookie = cookie
})
.catch((err) => {
throw prefixMessage(err, 'Error while re-fetching CSRF token.')
})
return await callback().catch((err: any) => {
throw prefixMessage(
err,
'Error while executing callback in handleError. '
)
})
}
if (response?.status === 403 || response?.status === 449) {
this.parseAndSetCsrfToken(response)
if (this.csrfToken.headerName && this.csrfToken.value) {
return await callback().catch((err: any) => {
throw prefixMessage(
err,
'Error while executing callback in handleError. '
)
})
}
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.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
}