From e0d85f458becc2c4dc9cd284f313c7e730e97432 Mon Sep 17 00:00:00 2001 From: Krishna Acondy Date: Sun, 24 Jan 2021 18:23:18 +0000 Subject: [PATCH] fix(*): store CSRF tokens in Request Client --- sasjs-tests/src/testSuites/Basic.ts | 24 +- src/ContextManager.ts | 263 +++++++------------ src/FileUploader.ts | 131 +++------- src/SASViyaApiClient.ts | 387 ++++++++-------------------- src/SASjs.ts | 39 +-- src/SessionManager.ts | 145 ++++------- src/request/client.ts | 262 +++++++++++++++++++ 7 files changed, 572 insertions(+), 679 deletions(-) create mode 100644 src/request/client.ts diff --git a/sasjs-tests/src/testSuites/Basic.ts b/sasjs-tests/src/testSuites/Basic.ts index a7f87a9..5f45be4 100644 --- a/sasjs-tests/src/testSuites/Basic.ts +++ b/sasjs-tests/src/testSuites/Basic.ts @@ -1,14 +1,15 @@ -import SASjs, { ServerType, SASjsConfig } from "@sasjs/adapter"; +import SASjs, { SASjsConfig } from "@sasjs/adapter"; import { TestSuite } from "@sasjs/test-framework"; +import { ServerType } from "@sasjs/utils/types"; const defaultConfig: SASjsConfig = { serverUrl: window.location.origin, - pathSAS9: '/SASStoredProcess/do', - pathSASViya: '/SASJobExecution', - appLoc: '/Public/seedapp', - serverType: ServerType.SASViya, + pathSAS9: "/SASStoredProcess/do", + pathSASViya: "/SASJobExecution", + appLoc: "/Public/seedapp", + serverType: ServerType.SasViya, debug: false, - contextName: 'SAS Job Execution compute context', + contextName: "SAS Job Execution compute context", useComputeApi: false }; @@ -17,7 +18,7 @@ const customConfig = { pathSAS9: "sas9", pathSASViya: "viya", appLoc: "/Public/seedapp", - serverType: ServerType.SAS9, + serverType: ServerType.Sas9, debug: false }; @@ -39,11 +40,12 @@ export const basicTests = ( }, { title: "Multiple Log in attempts", - description: "Should fail on first attempt and should log the user in on second attempt", + description: + "Should fail on first attempt and should log the user in on second attempt", test: async () => { - await adapter.logOut() - await adapter.logIn('invalid', 'invalid') - return adapter.logIn(userName, password) + await adapter.logOut(); + await adapter.logIn("invalid", "invalid"); + return adapter.logIn(userName, password); }, assertion: (response: any) => response && response.isLoggedIn && response.userName === userName diff --git a/src/ContextManager.ts b/src/ContextManager.ts index 576645e..c9a897c 100644 --- a/src/ContextManager.ts +++ b/src/ContextManager.ts @@ -1,11 +1,7 @@ -import { - Context, - CsrfToken, - EditContextInput, - ContextAllAttributes -} from './types' -import { makeRequest, isUrl } from './utils' +import { Context, EditContextInput, ContextAllAttributes } from './types' +import { isUrl } from './utils' import { prefixMessage } from '@sasjs/utils/error' +import { RequestClient } from './request/client' export class ContextManager { private defaultComputeContexts = [ @@ -28,8 +24,6 @@ export class ContextManager { 'SAS Visual Forecasting launcher context' ] - private csrfToken: CsrfToken | null = null - get getDefaultComputeContexts() { return this.defaultComputeContexts } @@ -37,28 +31,19 @@ export class ContextManager { return this.defaultLauncherContexts } - constructor( - private serverUrl: string, - private setCsrfToken: (csrfToken: CsrfToken) => void - ) { + constructor(private serverUrl: string, private requestClient: RequestClient) { if (serverUrl) isUrl(serverUrl) } public async getComputeContexts(accessToken?: string) { - const headers: any = { - 'Content-Type': 'application/json' - } - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - const { result: contexts } = await this.request<{ items: Context[] }>( - `${this.serverUrl}/compute/contexts?limit=10000`, - { headers } - ).catch((err) => { - throw prefixMessage(err, 'Error while getting compute contexts. ') - }) + const { result: contexts } = await this.requestClient + .get<{ items: Context[] }>( + `${this.serverUrl}/compute/contexts?limit=10000`, + accessToken + ) + .catch((err) => { + throw prefixMessage(err, 'Error while getting compute contexts. ') + }) const contextsList = contexts && contexts.items ? contexts.items : [] @@ -72,20 +57,14 @@ export class ContextManager { } public async getLauncherContexts(accessToken?: string) { - const headers: any = { - 'Content-Type': 'application/json' - } - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - const { result: contexts } = await this.request<{ items: Context[] }>( - `${this.serverUrl}/launcher/contexts?limit=10000`, - { headers } - ).catch((err) => { - throw prefixMessage(err, 'Error while getting launcher contexts. ') - }) + const { result: contexts } = await this.requestClient + .get<{ items: Context[] }>( + `${this.serverUrl}/launcher/contexts?limit=10000`, + accessToken + ) + .catch((err) => { + throw prefixMessage(err, 'Error while getting launcher contexts. ') + }) const contextsList = contexts && contexts.items ? contexts.items : [] @@ -183,18 +162,15 @@ export class ContextManager { requestBody.environment = { autoExecLines } } - const createContextRequest: RequestInit = { - method: 'POST', - headers, - body: JSON.stringify(requestBody) - } - - const { result: context } = await this.request( - `${this.serverUrl}/compute/contexts`, - createContextRequest - ).catch((err) => { - throw prefixMessage(err, 'Error while creating compute context. ') - }) + const { result: context } = await this.requestClient + .post( + `${this.serverUrl}/compute/contexts`, + JSON.stringify(requestBody), + accessToken + ) + .catch((err) => { + throw prefixMessage(err, 'Error while creating compute context. ') + }) return context } @@ -237,18 +213,15 @@ export class ContextManager { launchType } - const createContextRequest: RequestInit = { - method: 'POST', - headers, - body: JSON.stringify(requestBody) - } - - const { result: context } = await this.request( - `${this.serverUrl}/launcher/contexts`, - createContextRequest - ).catch((err) => { - throw prefixMessage(err, 'Error while creating launcher context. ') - }) + const { result: context } = await this.requestClient + .post( + `${this.serverUrl}/launcher/contexts`, + JSON.stringify(requestBody), + accessToken + ) + .catch((err) => { + throw prefixMessage(err, 'Error while creating launcher context. ') + }) return context } @@ -267,14 +240,6 @@ export class ContextManager { true ) - const headers: any = { - 'Content-Type': 'application/json' - } - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - let originalContext originalContext = await this.getComputeContextByName( @@ -290,39 +255,33 @@ export class ContextManager { ) } - const { result: context, etag } = await this.request( - `${this.serverUrl}/compute/contexts/${originalContext.id}`, - { - headers - } - ).catch((err) => { - if (err && err.status === 404) { - throw new Error( - `The context '${contextName}' was not found on this server.` - ) - } + const { result: context, etag } = await this.requestClient + .get( + `${this.serverUrl}/compute/contexts/${originalContext.id}`, + accessToken + ) + .catch((err) => { + if (err && err.status === 404) { + throw new Error( + `The context '${contextName}' was not found on this server.` + ) + } - throw err - }) + throw err + }) // An If-Match header with the value of the last ETag for the context // is required to be able to update it // https://developer.sas.com/apis/rest/Compute/#update-a-context-definition - headers['If-Match'] = etag - - const updateContextRequest: RequestInit = { - method: 'PUT', - headers, - body: JSON.stringify({ + return await this.requestClient.put( + `/compute/contexts/${context.id}`, + JSON.stringify({ ...context, ...editedContext, attributes: { ...context.attributes, ...editedContext.attributes } - }) - } - - return await this.request( - `${this.serverUrl}/compute/contexts/${context.id}`, - updateContextRequest + }), + accessToken, + { 'If-Match': etag } ) } @@ -330,20 +289,17 @@ export class ContextManager { contextName: string, accessToken?: string ): Promise { - const headers: any = { - 'Content-Type': 'application/json' - } - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - const { result: contexts } = await this.request<{ items: Context[] }>( - `${this.serverUrl}/compute/contexts?filter=eq(name, "${contextName}")`, - { headers } - ).catch((err) => { - throw prefixMessage(err, 'Error while getting compute context by name. ') - }) + const { result: contexts } = await this.requestClient + .get<{ items: Context[] }>( + `${this.serverUrl}/compute/contexts?filter=eq(name, "${contextName}")`, + accessToken + ) + .catch((err) => { + throw prefixMessage( + err, + 'Error while getting compute context by name. ' + ) + }) if (!contexts || !(contexts.items && contexts.items.length)) { throw new Error( @@ -358,20 +314,16 @@ export class ContextManager { contextId: string, accessToken?: string ): Promise { - const headers: any = { - 'Content-Type': 'application/json' - } - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - const { result: context } = await this.request( - `${this.serverUrl}/compute/contexts/${contextId}`, - { headers } - ).catch((err) => { - throw prefixMessage(err, 'Error while getting compute context by id. ') - }) + const { + result: context + } = await this.requestClient + .get( + `${this.serverUrl}/compute/contexts/${contextId}`, + accessToken + ) + .catch((err) => { + throw prefixMessage(err, 'Error while getting compute context by id. ') + }) return context } @@ -380,20 +332,14 @@ export class ContextManager { executeScript: Function, accessToken?: string ) { - const headers: any = { - 'Content-Type': 'application/json' - } - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - const { result: contexts } = await this.request<{ items: Context[] }>( - `${this.serverUrl}/compute/contexts?limit=10000`, - { headers } - ).catch((err) => { - throw prefixMessage(err, 'Error while fetching compute contexts.') - }) + const { result: contexts } = await this.requestClient + .get<{ items: Context[] }>( + `${this.serverUrl}/compute/contexts?limit=10000`, + accessToken + ) + .catch((err) => { + throw prefixMessage(err, 'Error while fetching compute contexts.') + }) const contextsList = contexts.items || [] const executableContexts: any[] = [] @@ -470,14 +416,9 @@ export class ContextManager { const context = await this.getComputeContextByName(contextName, accessToken) - const deleteContextRequest: RequestInit = { - method: 'DELETE', - headers - } - - return await this.request( + return await this.requestClient.delete( `${this.serverUrl}/compute/contexts/${context.id}`, - deleteContextRequest + accessToken ) } @@ -485,34 +426,6 @@ export class ContextManager { // TODO: implement deleteLauncherContext method - private async request( - url: string, - options: RequestInit, - contentType: 'text' | 'json' = 'json' - ) { - if (this.csrfToken) { - options.headers = { - ...options.headers, - [this.csrfToken.headerName]: this.csrfToken.value - } - } - - return await makeRequest( - url, - options, - (token) => { - this.csrfToken = token - this.setCsrfToken(token) - }, - contentType - ).catch((err) => { - throw prefixMessage( - err, - 'Error while making request in Context Manager. ' - ) - }) - } - private validateContextName(name: string) { if (!name) throw new Error('Context name is required.') } diff --git a/src/FileUploader.ts b/src/FileUploader.ts index 1979d27..05f2453 100644 --- a/src/FileUploader.ts +++ b/src/FileUploader.ts @@ -1,116 +1,63 @@ -import { needsRetry, isUrl } from './utils' -import { CsrfToken } from './types/CsrfToken' +import { isUrl } from './utils' import { UploadFile } from './types/UploadFile' import { ErrorResponse } from './types' -import axios, { AxiosInstance } from 'axios' -import { isLogInRequired } from './auth' - -const requestRetryLimit = 5 +import { RequestClient } from './request/client' export class FileUploader { - private httpClient: AxiosInstance - constructor( private appLoc: string, serverUrl: string, private jobsPath: string, - private setCsrfTokenWeb: any, - private csrfToken: CsrfToken | null = null + private requestClient: RequestClient ) { if (serverUrl) isUrl(serverUrl) - this.httpClient = axios.create({ baseURL: serverUrl }) } - private retryCount = 0 - public uploadFile(sasJob: string, files: UploadFile[], params: any) { - return new Promise((resolve, reject) => { - if (files?.length < 1) - reject(new ErrorResponse('At least one file must be provided.')) - if (!sasJob || sasJob === '') - reject(new ErrorResponse('sasJob must be provided.')) + if (files?.length < 1) + return Promise.reject( + new ErrorResponse('At least one file must be provided.') + ) + if (!sasJob || sasJob === '') + return Promise.reject(new ErrorResponse('sasJob must be provided.')) - let paramsString = '' + let paramsString = '' - for (let param in params) { - if (params.hasOwnProperty(param)) { - paramsString += `&${param}=${params[param]}` - } + for (let param in params) { + if (params.hasOwnProperty(param)) { + paramsString += `&${param}=${params[param]}` } + } - const program = this.appLoc - ? this.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '') - : sasJob - const uploadUrl = `${this.jobsPath}/?${ - '_program=' + program - }${paramsString}` + const program = this.appLoc + ? this.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '') + : sasJob + const uploadUrl = `${this.jobsPath}/?${ + '_program=' + program + }${paramsString}` - const headers = { - 'cache-control': 'no-cache' - } + const formData = new FormData() - const formData = new FormData() + for (let file of files) { + formData.append('file', file.file, file.fileName) + } - for (let file of files) { - formData.append('file', file.file, file.fileName) - } + const csrfToken = this.requestClient.getCsrfToken('file') + if (csrfToken) formData.append('_csrf', csrfToken.value) - if (this.csrfToken) formData.append('_csrf', this.csrfToken.value) + const headers = { + 'cache-control': 'no-cache', + Accept: '*/*', + 'Content-Type': 'text/plain' + } - this.httpClient - .post(uploadUrl, formData, { responseType: 'text', headers }) - .then(async (response) => { - if (response.status !== 200) { - if (response.status === 403) { - const tokenHeader = response.headers.get('X-CSRF-HEADER') - - if (tokenHeader) { - const token = response.headers.get(tokenHeader) - this.csrfToken = { - headerName: tokenHeader, - value: token || '' - } - - this.setCsrfTokenWeb(this.csrfToken) - } - } - } - - return response.data - }) - .then((responseText) => { - if (isLogInRequired(responseText)) - reject(new ErrorResponse('You must be logged in to upload a file.')) - - if (needsRetry(responseText)) { - if (this.retryCount < requestRetryLimit) { - this.retryCount++ - this.uploadFile(sasJob, files, params).then( - (res: any) => resolve(res), - (err: any) => reject(err) - ) - } else { - this.retryCount = 0 - reject(responseText) - } - } else { - this.retryCount = 0 - - try { - resolve(JSON.parse(responseText)) - } catch (e) { - reject( - new ErrorResponse( - 'Error while parsing json from upload response.', - e - ) - ) - } - } - }) - .catch((err: any) => { - reject(new ErrorResponse('Upload request failed.', err)) - }) - }) + return this.requestClient + .post(uploadUrl, formData, undefined, headers) + .then((res) => res.result) + .catch((err: Error) => { + return Promise.reject( + new ErrorResponse('File upload request failed', err) + ) + }) } } diff --git a/src/SASViyaApiClient.ts b/src/SASViyaApiClient.ts index 0770a0a..01c1350 100644 --- a/src/SASViyaApiClient.ts +++ b/src/SASViyaApiClient.ts @@ -6,7 +6,6 @@ import { Context, ContextAllAttributes, Folder, - CsrfToken, EditContextInput, JobDefinition, PollOptions @@ -16,35 +15,34 @@ import { SessionManager } from './SessionManager' import { ContextManager } from './ContextManager' import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time' import { Logger, LogLevel } from '@sasjs/utils/logger' -import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired' import { parseAndSubmitAuthorizeForm } from './auth' +import { RequestClient } from './request/client' /** * A client for interfacing with the SAS Viya REST API. * */ export class SASViyaApiClient { - private httpClient: AxiosInstance constructor( private serverUrl: string, private rootFolderName: string, private contextName: string, - private setCsrfToken: (csrfToken: CsrfToken) => void + private requestClient: RequestClient ) { if (serverUrl) isUrl(serverUrl) - this.httpClient = axios.create({ baseURL: serverUrl }) } - private csrfToken: CsrfToken | null = null - private fileUploadCsrfToken: CsrfToken | null = null private _debug = false private sessionManager = new SessionManager( this.serverUrl, this.contextName, - this.setCsrfToken + this.requestClient + ) + private contextManager = new ContextManager( + this.serverUrl, + this.requestClient ) - private contextManager = new ContextManager(this.serverUrl, this.setCsrfToken) private folderMap = new Map() public get debug() { @@ -143,10 +141,9 @@ export class SASViyaApiClient { headers.Authorization = `Bearer ${accessToken}` } - const { result: contexts } = await this.get<{ items: Context[] }>( - `/compute/contexts?limit=10000`, - accessToken - ) + const { result: contexts } = await this.requestClient.get<{ + items: Context[] + }>(`/compute/contexts?limit=10000`, accessToken) const executionContext = contexts.items && contexts.items.length @@ -163,7 +160,7 @@ export class SASViyaApiClient { 'Content-Type': 'application/json' } } - const { result: createdSession } = await this.post( + const { result: createdSession } = await this.requestClient.post( `/compute/contexts/${executionContext.id}/sessions`, {}, accessToken @@ -376,13 +373,15 @@ export class SASViyaApiClient { variables: jobVariables, arguments: jobArguments }) - const { result: postedJob, etag } = await this.post( - `/compute/sessions/${executionSessionId}/jobs`, - jobRequestBody, - accessToken - ).catch((err: any) => { - throw err - }) + const { result: postedJob, etag } = await this.requestClient + .post( + `/compute/sessions/${executionSessionId}/jobs`, + jobRequestBody, + accessToken + ) + .catch((err: any) => { + throw err + }) if (!waitForResult) { return session @@ -404,12 +403,14 @@ export class SASViyaApiClient { pollOptions ) - const { result: currentJob } = await this.get( - `/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`, - accessToken - ).catch((err) => { - throw err - }) + const { result: currentJob } = await this.requestClient + .get( + `/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`, + accessToken + ) + .catch((err) => { + throw err + }) let jobResult let log @@ -417,10 +418,8 @@ export class SASViyaApiClient { const logLink = currentJob.links.find((l) => l.rel === 'log') if (debug && logLink) { - log = await this.get( - `${logLink.href}/content?limit=10000`, - accessToken - ) + log = await this.requestClient + .get(`${logLink.href}/content?limit=10000`, accessToken) .then((res: any) => res.result.items.map((i: any) => i.line).join('\n') ) @@ -442,34 +441,30 @@ export class SASViyaApiClient { } if (resultLink) { - jobResult = await this.get( - resultLink, - accessToken, - 'text/plain' - ).catch(async (e) => { - if (e && e.status === 404) { - if (logLink) { - log = await this.get( - `${logLink.href}/content?limit=10000`, - accessToken - ) - .then((res: any) => - res.result.items.map((i: any) => i.line).join('\n') - ) - .catch((err) => { - throw err - }) + jobResult = await this.requestClient + .get(resultLink, accessToken, 'text/plain') + .catch(async (e) => { + if (e && e.status === 404) { + if (logLink) { + log = await this.requestClient + .get(`${logLink.href}/content?limit=10000`, accessToken) + .then((res: any) => + res.result.items.map((i: any) => i.line).join('\n') + ) + .catch((err) => { + throw err + }) - return Promise.reject({ - status: 500, - log - }) + return Promise.reject({ + status: 500, + log + }) + } } - } - return { - result: JSON.stringify(e) - } - }) + return { + result: JSON.stringify(e) + } + }) } await this.sessionManager @@ -559,7 +554,9 @@ export class SASViyaApiClient { } } - const { result: createFolderResponse } = await this.post( + const { + result: createFolderResponse + } = await this.requestClient.post( `/folders/folders?parentFolderUri=${parentFolderUri}`, JSON.stringify({ name: folderName, @@ -599,7 +596,7 @@ export class SASViyaApiClient { parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken) } - return await this.post( + return await this.requestClient.post( `${this.serverUrl}/jobDefinitions/definitions?parentFolderUri=${parentFolderUri}`, JSON.stringify({ name: jobName, @@ -763,7 +760,7 @@ export class SASViyaApiClient { headers.Authorization = `Bearer ${accessToken}` } - const deleteResponse = await this.delete(url, accessToken) + const deleteResponse = await this.requestClient.delete(url, accessToken) return deleteResponse.result } @@ -835,7 +832,9 @@ export class SASViyaApiClient { throw new Error(`URI of job definition was not found.`) } - const { result: jobDefinition } = await this.get( + const { + result: jobDefinition + } = await this.requestClient.get( `${this.serverUrl}${jobDefinitionLink.href}`, accessToken ) @@ -913,7 +912,7 @@ export class SASViyaApiClient { (l) => l.rel === 'getResource' )?.href - const { result: jobDefinition } = await this.get( + const { result: jobDefinition } = await this.requestClient.get( `${this.serverUrl}${jobDefinitionLink}`, accessToken ) @@ -948,12 +947,13 @@ export class SASViyaApiClient { jobDefinition, arguments: jobArguments }) - const { result: postedJob, etag } = await this.post( + const { result: postedJob, etag } = await this.requestClient.post( `${this.serverUrl}/jobExecution/jobs?_action=wait`, - postJobRequestBody + postJobRequestBody, + accessToken ) const jobStatus = await this.pollJobState(postedJob, etag, accessToken) - const { result: currentJob } = await this.get( + const { result: currentJob } = await this.requestClient.get( `${this.serverUrl}/jobExecution/jobs/${postedJob.id}`, accessToken ) @@ -964,17 +964,16 @@ export class SASViyaApiClient { const resultLink = currentJob.results['_webout.json'] const logLink = currentJob.links.find((l) => l.rel === 'log') if (resultLink) { - jobResult = await this.get( + jobResult = await this.requestClient.get( `${this.serverUrl}${resultLink}/content`, accessToken, 'text/plain' ) } if (debug && logLink) { - log = await this.get( - `${this.serverUrl}${logLink.href}/content`, - accessToken - ).then((res: any) => res.result.items.map((i: any) => i.line).join('\n')) + log = await this.requestClient + .get(`${this.serverUrl}${logLink.href}/content`, accessToken) + .then((res: any) => res.result.items.map((i: any) => i.line).join('\n')) } if (jobStatus === 'failed') { return Promise.reject({ error: currentJob.error, log }) @@ -991,11 +990,14 @@ export class SASViyaApiClient { } const url = '/folders/folders/@item?path=' + path - const { result: folder } = await this.get(`${url}`, accessToken) + const { result: folder } = await this.requestClient.get( + `${url}`, + accessToken + ) if (!folder) { throw new Error(`The path ${path} does not exist on ${this.serverUrl}`) } - const { result: members } = await this.get<{ items: any[] }>( + const { result: members } = await this.requestClient.get<{ items: any[] }>( `/folders/folders/${folder.id}/members?limit=${folder.memberCount}`, accessToken ) @@ -1033,7 +1035,7 @@ export class SASViyaApiClient { Promise.reject(`Job state link was not found.`) } - const { result: state } = await this.get( + const { result: state } = await this.requestClient.get( `${this.serverUrl}${stateLink.href}?_action=wait&wait=30`, accessToken, 'text/plain' @@ -1054,7 +1056,7 @@ export class SASViyaApiClient { postedJobState === 'pending' ) { if (stateLink) { - const { result: jobState } = await this.get( + const { result: jobState } = await this.requestClient.get( `${this.serverUrl}${stateLink.href}?_action=wait&wait=30`, accessToken, 'text/plain' @@ -1100,7 +1102,7 @@ export class SASViyaApiClient { ) } - const uploadResponse = await this.uploadFile( + const uploadResponse = await this.requestClient.uploadFile( `${this.serverUrl}/files/files#rawUpload`, csv, accessToken @@ -1113,12 +1115,11 @@ export class SASViyaApiClient { private async getFolderUri(folderPath: string, accessToken?: string) { const url = '/folders/folders/@item?path=' + folderPath - const { result: folder } = await this.get( - `${this.serverUrl}${url}`, - accessToken - ).catch(() => { - return { result: null } - }) + const { result: folder } = await this.requestClient + .get(`${this.serverUrl}${url}`, accessToken) + .catch(() => { + return { result: null } + }) if (!folder) return undefined return `/folders/folders/${folder.id}` @@ -1127,12 +1128,11 @@ export class SASViyaApiClient { private async getRecycleBinUri(accessToken: string) { const url = '/folders/folders/@myRecycleBin' - const { result: folder } = await this.get( - `${this.serverUrl}${url}`, - accessToken - ).catch(() => { - return { result: null } - }) + const { result: folder } = await this.requestClient + .get(`${this.serverUrl}${url}`, accessToken) + .catch(() => { + return { result: null } + }) if (!folder) return undefined @@ -1196,27 +1196,31 @@ export class SASViyaApiClient { const sourceFolderId = sourceFolderUri?.split('/').pop() const url = sourceFolderUri - const { result: folder } = await this.patch( - `${this.serverUrl}${url}`, - JSON.stringify({ - id: sourceFolderId, - name: targetFolderName, - parentFolderUri: targetParentFolderUri - }), - accessToken - ).catch((err) => { - if (err.code && err.code === 'ENOTFOUND') { - const notFoundError = { - body: JSON.stringify({ - message: `Folder '${sourceFolder.split('/').pop()}' was not found.` - }) + const { result: folder } = await this.requestClient + .patch( + `${this.serverUrl}${url}`, + JSON.stringify({ + id: sourceFolderId, + name: targetFolderName, + parentFolderUri: targetParentFolderUri + }), + accessToken + ) + .catch((err) => { + if (err.code && err.code === 'ENOTFOUND') { + const notFoundError = { + body: JSON.stringify({ + message: `Folder '${sourceFolder + .split('/') + .pop()}' was not found.` + }) + } + + throw notFoundError } - throw notFoundError - } - - throw err - }) + throw err + }) if (!folder) return undefined @@ -1244,175 +1248,4 @@ export class SASViyaApiClient { return movedFolder } - - setCsrfTokenLocal = (csrfToken: CsrfToken) => { - this.csrfToken = csrfToken - this.setCsrfToken(csrfToken) - } - - setFileUploadCsrfToken = (csrfToken: CsrfToken) => { - this.fileUploadCsrfToken = csrfToken - } - - private get( - url: string, - accessToken?: string, - contentType = 'application/json' - ) { - const headers: any = { - 'Content-Type': contentType - } - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - const requestConfig: AxiosRequestConfig = { - headers, - responseType: contentType === 'text/plain' ? 'text' : 'json', - withCredentials: true - } - if (contentType === 'text/plain') { - requestConfig.headers.Accept = '*/*' - requestConfig.transformResponse = undefined - } - - return this.httpClient.get(url, requestConfig).then((response) => ({ - result: response.data, - etag: response.headers['etag'] - })) - } - - private post( - url: string, - data: any = {}, - accessToken?: string - ): Promise<{ result: T; etag: string }> { - const headers: any = { - 'Content-Type': 'application/json' - } - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - if (this.csrfToken?.value) { - headers[this.csrfToken.headerName] = this.csrfToken.value - } - - return this.httpClient - .post(url, data, { headers, withCredentials: true }) - .then((response) => { - return { - result: response.data as T, - etag: response.headers['etag'] as string - } - }) - .catch((e) => { - const response = e.response as AxiosResponse - if (response.status === 403 || response.status === 449) { - const tokenHeader = (response.headers[ - 'x-csrf-header' - ] as string)?.toLowerCase() - - if (tokenHeader) { - const token = response.headers[tokenHeader] - this.setCsrfTokenLocal({ - headerName: tokenHeader, - value: token || '' - }) - return this.post(url, data, accessToken) - } - throw e - } - throw e - }) - } - - private delete(url: string, accessToken?: string) { - const headers: any = {} - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - if (this.csrfToken?.value) { - headers[this.csrfToken.headerName] = this.csrfToken.value - } - - const requestConfig: AxiosRequestConfig = { - headers - } - - return this.httpClient.delete(url, requestConfig).then((response) => ({ - result: response.data, - etag: response.headers['etag'] - })) - } - - private patch(url: string, data: any = {}, accessToken?: string) { - const headers: any = { - 'Content-Type': 'application/json' - } - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - if (this.csrfToken?.value) { - headers[this.csrfToken.headerName] = this.csrfToken.value - } - - return this.httpClient - .patch(url, data, { headers, withCredentials: true }) - .then((response) => { - return { - result: response.data as T, - etag: response.headers['etag'] as string - } - }) - } - - private uploadFile( - url: string, - content: string, - accessToken?: string - ): Promise { - const headers: any = { - 'Content-Type': 'application/json' - } - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - if (this.fileUploadCsrfToken?.value) { - headers[ - this.fileUploadCsrfToken.headerName - ] = this.fileUploadCsrfToken.value - } - - return this.httpClient - .post(url, content, { headers, withCredentials: true }) - .then((response) => { - return { - result: response.data, - etag: response.headers['etag'] as string - } - }) - .catch((e) => { - const response = e.response as AxiosResponse - if (response.status === 403 || response.status === 449) { - const tokenHeader = (response.headers[ - 'x-csrf-header' - ] as string)?.toLowerCase() - - if (tokenHeader) { - const token = response.headers[tokenHeader] - this.setFileUploadCsrfToken({ - headerName: tokenHeader, - value: token || '' - }) - return this.uploadFile(url, content, accessToken) - } - throw e - } - throw e - }) - } } diff --git a/src/SASjs.ts b/src/SASjs.ts index eff7c10..945a7a1 100644 --- a/src/SASjs.ts +++ b/src/SASjs.ts @@ -24,6 +24,7 @@ import { SAS9ApiClient } from './SAS9ApiClient' import { FileUploader } from './FileUploader' import { isLogInRequired, AuthManager } from './auth' import { ServerType } from '@sasjs/utils/types' +import { RequestClient } from './request/client' const defaultConfig: SASjsConfig = { serverUrl: '', @@ -45,7 +46,6 @@ const requestRetryLimit = 5 export default class SASjs { private sasjsConfig: SASjsConfig = new SASjsConfig() private jobsPath: string = '' - private csrfTokenApi: CsrfToken | null = null private csrfTokenWeb: CsrfToken | null = null private retryCountWeb: number = 0 private retryCountComputeApi: number = 0 @@ -56,6 +56,7 @@ export default class SASjs { private sas9ApiClient: SAS9ApiClient | null = null private fileUploader: FileUploader | null = null private authManager: AuthManager | null = null + private requestClient: RequestClient | null = null constructor(config?: any) { this.sasjsConfig = { @@ -420,22 +421,6 @@ export default class SASjs { return this.authManager!.userName } - /** - * Returns the _csrf token of the current session for the API approach. - * - */ - public getCsrfApi() { - return this.csrfTokenApi?.value - } - - /** - * Returns the _csrf token of the current session for the WEB approach. - * - */ - public getCsrfWeb() { - return this.csrfTokenWeb?.value - } - /** * Sets the SASjs configuration. * @param config - SASjs configuration. @@ -498,8 +483,7 @@ export default class SASjs { this.sasjsConfig.appLoc, this.sasjsConfig.serverUrl, this.jobsPath, - this.setCsrfTokenWeb, - this.csrfTokenWeb + this.requestClient! ) return fileUploader.uploadFile(sasJob, files, params) @@ -604,7 +588,7 @@ export default class SASjs { serverUrl, appLoc, this.sasjsConfig.contextName, - this.setCsrfTokenApi + this.requestClient! ) sasApiClient.debug = this.sasjsConfig.debug } else if (this.sasjsConfig.serverType === ServerType.Sas9) { @@ -1150,14 +1134,6 @@ export default class SASjs { return sasjsWaitingRequest.requestPromise.promise } - private setCsrfTokenWeb = (csrfToken: CsrfToken) => { - this.csrfTokenWeb = csrfToken - } - - private setCsrfTokenApi = (csrfToken: CsrfToken) => { - this.csrfTokenApi = csrfToken - } - private resendWaitingRequests = async () => { for (const sasjsWaitingRequest of this.sasjsWaitingRequests) { this.request(sasjsWaitingRequest.SASjob, sasjsWaitingRequest.data).then( @@ -1396,10 +1372,13 @@ export default class SASjs { this.sasjsConfig.serverUrl = this.sasjsConfig.serverUrl.slice(0, -1) } + this.requestClient = new RequestClient(this.sasjsConfig.serverUrl) + this.jobsPath = this.sasjsConfig.serverType === ServerType.SasViya ? this.sasjsConfig.pathSASViya : this.sasjsConfig.pathSAS9 + this.authManager = new AuthManager( this.sasjsConfig.serverUrl, this.sasjsConfig.serverType!, @@ -1417,7 +1396,7 @@ export default class SASjs { this.sasjsConfig.serverUrl, this.sasjsConfig.appLoc, this.sasjsConfig.contextName, - this.setCsrfTokenApi + this.requestClient ) this.sasViyaApiClient.debug = this.sasjsConfig.debug @@ -1432,7 +1411,7 @@ export default class SASjs { this.sasjsConfig.appLoc, this.sasjsConfig.serverUrl, this.jobsPath, - this.setCsrfTokenWeb + this.requestClient ) } diff --git a/src/SessionManager.ts b/src/SessionManager.ts index 1b63f17..54d1476 100644 --- a/src/SessionManager.ts +++ b/src/SessionManager.ts @@ -1,6 +1,7 @@ import { Session, Context, CsrfToken, SessionVariable } from './types' -import { asyncForEach, makeRequest, isUrl } from './utils' +import { asyncForEach, isUrl } from './utils' import { prefixMessage } from '@sasjs/utils/error' +import { RequestClient } from './request/client' const MAX_SESSION_COUNT = 1 const RETRY_LIMIT: number = 3 @@ -14,7 +15,7 @@ export class SessionManager { constructor( private serverUrl: string, private contextName: string, - private setCsrfToken: (csrfToken: CsrfToken) => void + private requestClient: RequestClient ) { if (serverUrl) isUrl(serverUrl) } @@ -63,10 +64,8 @@ export class SessionManager { headers: this.getHeaders(accessToken) } - return await this.request( - `${this.serverUrl}/compute/sessions/${id}`, - deleteSessionRequest - ) + return await this.requestClient + .delete(`/compute/sessions/${id}`, accessToken) .then(() => { this.sessions = this.sessions.filter((s) => s.id !== id) }) @@ -98,17 +97,20 @@ export class SessionManager { } private async createAndWaitForSession(accessToken?: string) { - const createSessionRequest = { - method: 'POST', - headers: this.getHeaders(accessToken) - } - - const { result: createdSession, etag } = await this.request( - `${this.serverUrl}/compute/contexts/${this.currentContext!.id}/sessions`, - createSessionRequest - ).catch((err) => { - throw err - }) + const { + result: createdSession, + etag + } = await this.requestClient + .post( + `${this.serverUrl}/compute/contexts/${ + this.currentContext!.id + }/sessions`, + {}, + accessToken + ) + .catch((err) => { + throw err + }) await this.waitForSession(createdSession, etag, accessToken) @@ -119,13 +121,13 @@ export class SessionManager { private async setCurrentContext(accessToken?: string) { if (!this.currentContext) { - const { result: contexts } = await this.request<{ - items: Context[] - }>(`${this.serverUrl}/compute/contexts?limit=10000`, { - headers: this.getHeaders(accessToken) - }).catch((err) => { - throw err - }) + const { result: contexts } = await this.requestClient + .get<{ + items: Context[] + }>(`${this.serverUrl}/compute/contexts?limit=10000`, accessToken) + .catch((err) => { + throw err + }) const contextsList = contexts && contexts.items && contexts.items.length @@ -166,10 +168,7 @@ export class SessionManager { accessToken?: string ) { let sessionState = session.state - const headers: any = { - ...this.getHeaders(accessToken), - 'If-None-Match': etag - } + const stateLink = session.links.find((l: any) => l.rel === 'state') return new Promise(async (resolve, _) => { @@ -185,12 +184,10 @@ export class SessionManager { this.printedSessionState.printed = true } - const { result: state } = await this.requestSessionStatus( + const state = await this.getSessionState( `${this.serverUrl}${stateLink.href}?wait=30`, - { - headers - }, - 'text' + etag!, + accessToken ).catch((err) => { throw err }) @@ -223,73 +220,33 @@ export class SessionManager { }) } - private async request( + private async getSessionState( url: string, - options: RequestInit, - contentType: 'text' | 'json' = 'json' + etag: string, + accessToken?: string ) { - if (this.csrfToken) { - options.headers = { - ...options.headers, - [this.csrfToken.headerName]: this.csrfToken.value - } - } + return await this.requestClient + .get(url, accessToken, 'text/plain', { 'If-None-Match': etag }) + .then((res) => res.result as string) + .catch((err) => { + if (err.status === INTERNAL_SAS_ERROR.status) + return INTERNAL_SAS_ERROR.message - return await makeRequest( - url, - options, - (token) => { - this.csrfToken = token - this.setCsrfToken(token) - }, - contentType - ).catch((err) => { - throw err - }) - } - - private async requestSessionStatus( - url: string, - options: RequestInit, - contentType: 'text' | 'json' = 'json' - ) { - if (this.csrfToken) { - options.headers = { - ...options.headers, - [this.csrfToken.headerName]: this.csrfToken.value - } - } - - return await makeRequest( - url, - options, - (token) => { - this.csrfToken = token - this.setCsrfToken(token) - }, - contentType - ).catch((err) => { - if (err.status === INTERNAL_SAS_ERROR.status) - return { result: INTERNAL_SAS_ERROR.message } - - throw err - }) + throw err + }) } async getVariable(sessionId: string, variable: string, accessToken?: string) { - const getSessionVariable = { - method: 'GET', - headers: this.getHeaders(accessToken) - } - - return await this.request( - `${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`, - getSessionVariable - ).catch((err) => { - throw prefixMessage( - err, - `Error while fetching session variable '${variable}'.` + return await this.requestClient + .get( + `${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`, + accessToken ) - }) + .catch((err) => { + throw prefixMessage( + err, + `Error while fetching session variable '${variable}'.` + ) + }) } } diff --git a/src/request/client.ts b/src/request/client.ts new file mode 100644 index 0000000..a8a46eb --- /dev/null +++ b/src/request/client.ts @@ -0,0 +1,262 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import { CsrfToken } from '..' + +export class RequestClient { + private csrfToken: CsrfToken | undefined + private fileUploadCsrfToken: CsrfToken | undefined + private httpClient: AxiosInstance + + constructor(baseUrl: string) { + this.httpClient = axios.create({ baseURL: baseUrl }) + } + + public getCsrfToken(type: 'general' | 'file' = 'general') { + return type === 'file' ? this.fileUploadCsrfToken : this.csrfToken + } + + public async get( + url: string, + accessToken: string | undefined, + contentType: string = 'application/json', + overrideHeaders: { [key: string]: string | number } = {} + ): Promise<{ result: T; etag: string }> { + const headers = { + ...this.getHeaders(accessToken, contentType), + ...overrideHeaders + } + + const requestConfig: AxiosRequestConfig = { + headers, + responseType: contentType === 'text/plain' ? 'text' : 'json', + withCredentials: true + } + if (contentType === 'text/plain') { + requestConfig.headers.Accept = '*/*' + requestConfig.transformResponse = undefined + } + + try { + const response = await this.httpClient.get(url, requestConfig) + return { + result: response.data as T, + etag: response.headers['etag'] + } + } catch (e) { + const response_1 = e.response as AxiosResponse + if (response_1.status === 403 || response_1.status === 449) { + this.parseAndSetCsrfToken(response_1) + if (this.csrfToken) { + return this.get(url, accessToken, contentType, overrideHeaders) + } + throw e + } + throw e + } + } + + public post( + 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 + .post(url, data, { headers, withCredentials: true }) + .then((response) => { + return { + result: response.data as T, + etag: response.headers['etag'] as string + } + }) + .catch((e) => { + const response = e.response as AxiosResponse + if (response.status === 403 || response.status === 449) { + this.parseAndSetCsrfToken(response) + + if (this.csrfToken) { + return this.post(url, data, accessToken) + } + throw e + } + throw e + }) + } + + public async put( + 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 + } + + try { + const response = await this.httpClient.put(url, data, { + headers, + withCredentials: true + }) + return { + result: response.data as T, + etag: response.headers['etag'] as string + } + } catch (e) { + const response_1 = e.response as AxiosResponse + if (response_1.status === 403 || response_1.status === 449) { + this.parseAndSetCsrfToken(response_1) + + if (this.csrfToken) { + return this.put(url, data, accessToken) + } + throw e + } + throw e + } + } + + public async delete( + url: string, + accessToken?: string + ): Promise<{ result: T; etag: string }> { + const headers = this.getHeaders(accessToken, 'application/json') + + const requestConfig: AxiosRequestConfig = { + headers + } + + try { + const response = await this.httpClient.delete(url, requestConfig) + return { + result: response.data as T, + etag: response.headers['etag'] + } + } catch (e) { + const response_1 = e.response as AxiosResponse + if (response_1.status === 403 || response_1.status === 449) { + this.parseAndSetCsrfToken(response_1) + + if (this.csrfToken) { + return this.delete(url, accessToken) + } + throw e + } + throw e + } + } + + public async patch( + url: string, + data: any = {}, + accessToken?: string + ): Promise<{ result: T; etag: string }> { + const headers = this.getHeaders(accessToken, 'application/json') + + try { + const response = await this.httpClient.patch(url, data, { + headers, + withCredentials: true + }) + return { + result: response.data as T, + etag: response.headers['etag'] as string + } + } catch (e) { + const response_1 = e.response as AxiosResponse + if (response_1.status === 403 || response_1.status === 449) { + this.parseAndSetCsrfToken(response_1) + + if (this.csrfToken) { + return this.patch(url, accessToken) + } + throw e + } + throw e + } + } + + public async uploadFile( + url: string, + content: string, + accessToken?: string + ): Promise { + const headers = this.getHeaders(accessToken, 'application/json') + + if (this.fileUploadCsrfToken?.value) { + headers[ + this.fileUploadCsrfToken.headerName + ] = this.fileUploadCsrfToken.value + } + + try { + const response = await this.httpClient.post(url, content, { headers }) + return { + result: response.data, + etag: response.headers['etag'] as string + } + } catch (e) { + const response_1 = e.response as AxiosResponse + if (response_1.status === 403 || response_1.status === 449) { + this.parseAndSetFileUploadCsrfToken(response_1) + + if (this.fileUploadCsrfToken) { + return this.uploadFile(url, content, accessToken) + } + throw e + } + throw e + } + } + + private getHeaders(accessToken: string | undefined, contentType: string) { + const headers: any = { + 'Content-Type': contentType + } + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}` + } + if (this.csrfToken?.value) { + headers[this.csrfToken.headerName] = this.csrfToken.value + } + + return headers + } + + private parseAndSetFileUploadCsrfToken(response: AxiosResponse) { + const token = this.parseCsrfToken(response) + + if (token) { + this.fileUploadCsrfToken = token + } + } + + private parseAndSetCsrfToken(response: AxiosResponse) { + const token = this.parseCsrfToken(response) + + if (token) { + this.csrfToken = token + } + } + + private parseCsrfToken(response: AxiosResponse): CsrfToken | undefined { + const tokenHeader = (response.headers[ + 'x-csrf-header' + ] as string)?.toLowerCase() + + if (tokenHeader) { + const token = response.headers[tokenHeader] + const csrfToken = { + headerName: tokenHeader, + value: token || '' + } + + return csrfToken + } + } +}