Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(request-client): implemented verbose mode #824

Merged
merged 2 commits into from
Jul 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/SASjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,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 printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
* @param verboseMode - boolean to enable verbose mode (log every HTTP response).
*/
public async startComputeJob(
sasJob: string,
Expand All @@ -863,7 +864,8 @@ export default class SASjs {
waitForResult?: boolean,
pollOptions?: PollOptions,
printPid = false,
variables?: MacroVar
variables?: MacroVar,
verboseMode?: boolean
) {
config = {
...this.sasjsConfig,
Expand All @@ -877,6 +879,9 @@ export default class SASjs {
)
}

if (verboseMode) this.requestClient?.enableVerboseMode()
else this.requestClient?.disableVerboseMode()

return this.sasViyaApiClient?.executeComputeJob(
sasJob,
config.contextName,
Expand Down
103 changes: 103 additions & 0 deletions src/request/RequestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
createAxiosInstance
} from '../utils'
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
import { inspect } from 'util'

export interface HttpClient {
get<T>(
Expand Down Expand Up @@ -59,6 +60,7 @@ export interface HttpClient {
export class RequestClient implements HttpClient {
private requests: SASjsRequest[] = []
private requestsLimit: number = 10
private httpInterceptor?: number

protected csrfToken: CsrfToken = { headerName: '', value: '' }
protected fileUploadCsrfToken: CsrfToken | undefined
Expand All @@ -70,6 +72,7 @@ export class RequestClient implements HttpClient {
requestsLimit?: number
) {
this.createHttpClient(baseUrl, httpsAgentOptions)

if (requestsLimit) this.requestsLimit = requestsLimit
}

Expand Down Expand Up @@ -180,6 +183,7 @@ export class RequestClient implements HttpClient {
responseType: contentType === 'text/plain' ? 'text' : 'json',
withCredentials: true
}

if (contentType === 'text/plain') {
requestConfig.transformResponse = undefined
}
Expand Down Expand Up @@ -389,6 +393,105 @@ export class RequestClient implements HttpClient {
})
}

/**
* Adds colors to the string.
* @param str - string to be prettified.
* @returns - prettified string
*/
private prettifyString = (str: any) => inspect(str, { colors: true })

/**
* 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 = (response: AxiosResponse) => {
const { status, config, request, data: resData } = response
const { data: reqData } = config
const { _header: reqHeaders, res } = request
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 response
}

/**
* 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 = (
accessToken: string | undefined,
contentType: string
Expand Down
193 changes: 183 additions & 10 deletions src/test/RequestClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
} from '../types/errors'
import { RequestClient } from '../request/RequestClient'
import { getTokenRequestErrorPrefixResponse } from '../auth/getTokenRequestErrorPrefix'
import { AxiosResponse } from 'axios'
import { Logger, LogLevel } from '@sasjs/utils/logger'

const axiosActual = jest.requireActual('axios')

Expand All @@ -25,16 +27,6 @@ jest
const PORT = 8000
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', () => {
let server: http.Server

Expand Down Expand Up @@ -80,6 +72,187 @@ describe('RequestClient', () => {
expect(rejectionErrorMessage).toEqual(expectedError.message)
})

describe('defaultInterceptionCallBack', () => {
beforeAll(() => {
;(process as any).logger = new Logger(LogLevel.Off)
})

it('should log parsed response', () => {
jest.spyOn((process as any).logger, 'info')

const status = 200
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 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'
}
}
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 resHeaders = ['content-type', 'application/json']
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)
})
})

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) => {
console.log('success')

return response
}
const failureCallback = (response: AxiosResponse) => {
console.log('failure')

return response
}

requestClient.enableVerboseMode(successCallback, failureCallback)

expect(interceptorSpy).toHaveBeenCalledWith(
successCallback,
failureCallback
)
})
})

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', () => {
const requestClient = new RequestClient('https://localhost:8009')
const randomError = 'some error'
Expand Down