From 5bcd17096b031767939eadf45f0c1b2648c8b556 Mon Sep 17 00:00:00 2001 From: Yury Shkoda Date: Thu, 27 Jul 2023 19:29:51 +0300 Subject: [PATCH 1/2] feat(request-client): implemented verbose mode --- src/SASjs.ts | 6 +- src/request/RequestClient.ts | 80 ++++++++++++++ src/test/RequestClient.spec.ts | 193 +++++++++++++++++++++++++++++++-- 3 files changed, 268 insertions(+), 11 deletions(-) diff --git a/src/SASjs.ts b/src/SASjs.ts index e3c7677c..3e15a9d6 100644 --- a/src/SASjs.ts +++ b/src/SASjs.ts @@ -863,7 +863,8 @@ export default class SASjs { waitForResult?: boolean, pollOptions?: PollOptions, printPid = false, - variables?: MacroVar + variables?: MacroVar, + verboseMode?: boolean ) { config = { ...this.sasjsConfig, @@ -877,6 +878,9 @@ export default class SASjs { ) } + if (verboseMode) this.requestClient?.enableVerboseMode() + else this.requestClient?.disableVerboseMode() + return this.sasViyaApiClient?.executeComputeJob( sasJob, config.contextName, diff --git a/src/request/RequestClient.ts b/src/request/RequestClient.ts index ef218712..b3647811 100644 --- a/src/request/RequestClient.ts +++ b/src/request/RequestClient.ts @@ -20,6 +20,7 @@ import { createAxiosInstance } from '../utils' import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError' +import { inspect } from 'util' export interface HttpClient { get( @@ -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 @@ -70,6 +72,7 @@ export class RequestClient implements HttpClient { requestsLimit?: number ) { this.createHttpClient(baseUrl, httpsAgentOptions) + if (requestsLimit) this.requestsLimit = requestsLimit } @@ -180,6 +183,7 @@ export class RequestClient implements HttpClient { responseType: contentType === 'text/plain' ? 'text' : 'json', withCredentials: true } + if (contentType === 'text/plain') { requestConfig.transformResponse = undefined } @@ -389,6 +393,82 @@ export class RequestClient implements HttpClient { }) } + private prettifyString = (str: any) => inspect(str, { colors: true }) + + private parseInterceptedBody = (body: any) => { + if (!body) return '' + + let parsedBody + + if (typeof body === 'string') { + try { + parsedBody = JSON.parse(body) + } catch (error) { + parsedBody = body + } + } else { + parsedBody = body + } + + const bodyLines = this.prettifyString(parsedBody).split('\n') + + 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 + + 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) + + 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 + } + + public enableVerboseMode = ( + successCallBack = this.defaultInterceptionCallBack, + errorCallBack = this.defaultInterceptionCallBack + ) => { + this.httpInterceptor = this.httpClient.interceptors.response.use( + successCallBack, + errorCallBack + ) + } + + public disableVerboseMode = () => { + if (this.httpInterceptor) { + this.httpClient.interceptors.response.eject(this.httpInterceptor) + } + } + protected getHeaders = ( accessToken: string | undefined, contentType: string diff --git a/src/test/RequestClient.spec.ts b/src/test/RequestClient.spec.ts index fb9cb893..bef18bcc 100644 --- a/src/test/RequestClient.spec.ts +++ b/src/test/RequestClient.spec.ts @@ -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') @@ -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 @@ -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' From ccb8599f002434556c722e185cc4aca748f2b4c2 Mon Sep 17 00:00:00 2001 From: Yury Shkoda Date: Fri, 28 Jul 2023 11:55:52 +0300 Subject: [PATCH 2/2] docs(request-client): added comments --- src/SASjs.ts | 1 + src/request/RequestClient.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/SASjs.ts b/src/SASjs.ts index 3e15a9d6..c7ed3c02 100644 --- a/src/SASjs.ts +++ b/src/SASjs.ts @@ -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, diff --git a/src/request/RequestClient.ts b/src/request/RequestClient.ts index b3647811..6d45a9ee 100644 --- a/src/request/RequestClient.ts +++ b/src/request/RequestClient.ts @@ -393,13 +393,24 @@ 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) @@ -412,6 +423,7 @@ export class RequestClient implements HttpClient { const bodyLines = this.prettifyString(parsedBody).split('\n') + // Leaves first 50 lines if (bodyLines.length > 51) { bodyLines.splice(50) bodyLines.push('...') @@ -426,6 +438,8 @@ export class RequestClient implements HttpClient { const { _header: reqHeaders, res } = request const { rawHeaders } = res + // Converts an array of strings into a single string with the following format: + // : const resHeaders = rawHeaders.reduce( (acc: string, value: string, i: number) => { if (i % 2 === 0) { @@ -441,6 +455,7 @@ export class RequestClient implements HttpClient { const parsedResBody = this.parseInterceptedBody(resData) + // HTTP response summary. process.logger?.info(`HTTP Request (first 50 lines): ${reqHeaders}${this.parseInterceptedBody(reqData)} @@ -453,6 +468,11 @@ ${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 @@ -463,6 +483,9 @@ ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''} ) } + /** + * Turns off verbose mode to log every HTTP response. + */ public disableVerboseMode = () => { if (this.httpInterceptor) { this.httpClient.interceptors.response.eject(this.httpInterceptor)