@apistudio/apim-cli
Version:
CLI for API Management Products
442 lines (404 loc) • 14.1 kB
text/typescript
/**
* Copyright IBM Corp. 2024, 2025
*/
import { HttpClient } from './http-client.js';
import { AxiosClient } from './axios-client.js';
import { VCM } from '../variable-context-manager/context-manager.js';
import { Request, Payload, AuthOptions } from '../../schemas/test.schema.js';
import qs from 'qs';
import { parseStringPromise } from 'xml2js';
import _get from 'lodash/get.js';
import { LogWrapper } from '../../service/log-wrapper.js';
import { uploadedFileModel } from '../../model-factories/fileupload.factory.js';
import FormDataNode from 'form-data';
// List of known system variables
const defaultSystemVars = [
'response',
'requestHeaders',
'responseHeaders',
'requestBody',
'responseBody',
'requestUrl',
'requestMethod',
'responseStatus',
'responseStatusText',
'responseTime',
];
export class RestHandler {
constructor(private readonly httpClient: HttpClient = new AxiosClient()) {}
/**
* Remove properties that cause circular references from response/request objects
* @param obj The object to sanitize
* @returns A sanitized copy of the object
*/
private removeCircularProperties(obj: any): any {
if (!obj || typeof obj !== 'object') {
return obj;
}
// Handle arrays
if (Array.isArray(obj)) {
return obj.map((item) => this.removeCircularProperties(item));
}
// List of properties known to cause circular references in HTTP responses
const circularProps = new Set([
'socket',
'_httpMessage',
'req',
'request',
'connection',
'client',
'res',
'response',
'agent',
'httpAgent',
'httpsAgent',
'_events',
'_eventsCount',
'_maxListeners',
'parser',
'_consuming',
'_dumped',
'httpVersion',
'httpVersionMajor',
'httpVersionMinor',
'complete',
'rawHeaders',
'rawTrailers',
'aborted',
'upgrade',
'_readableState',
'_writableState',
'readable',
'writable',
]);
// Check if this object has any circular reference properties
const hasCircularProps = Object.keys(obj).some((key) =>
circularProps.has(key),
);
// If no circular properties found, return the object as-is (it's likely a simple error response)
if (!hasCircularProps) {
return obj;
}
// Create a shallow copy to avoid mutating the original
const sanitized: any = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
// Skip circular reference properties
if (circularProps.has(key)) {
continue;
}
const value = obj[key];
// Recursively sanitize nested objects
if (value && typeof value === 'object') {
// Only recurse for plain objects and arrays, not special objects
if (Array.isArray(value) || value.constructor === Object) {
sanitized[key] = this.removeCircularProperties(value);
} else {
// For other object types (like Buffer, Date, Error, etc.), keep as is
sanitized[key] = value;
}
} else {
sanitized[key] = value;
}
}
}
return sanitized;
}
async setValues(
start: number,
response: any,
request: any,
contextId: string,
step: Request,
) {
const responseTime = Date.now() - start;
const vcm = VCM.getContext(contextId);
// Sanitize request and response objects before storing in VCM
const sanitizedRequest = this.removeCircularProperties(request);
const sanitizedResponse = this.removeCircularProperties(response);
// Store all information for assertions
vcm.set('request', sanitizedRequest);
vcm.set('response', sanitizedResponse);
vcm.set('requestHeaders', sanitizedRequest.headers);
vcm.set('responseHeaders', sanitizedResponse.headers);
vcm.set('requestBody', sanitizedRequest.data);
let parsedResponseData: any = sanitizedResponse.data;
const contentType = sanitizedResponse.headers?.['content-type'] || '';
const isXML = contentType.includes('application/xml');
if (isXML) {
parsedResponseData = await parseStringPromise(sanitizedResponse.data, {
explicitArray: false,
});
// @deprecated
vcm.set('xml()', parsedResponseData);
} else {
// @deprecated
vcm.set('json()', parsedResponseData);
}
vcm.set('responseBody', parsedResponseData);
vcm.set('requestUrl', sanitizedRequest.url);
vcm.set('requestMethod', sanitizedRequest.method);
vcm.set('responseStatus', sanitizedResponse.status);
vcm.set('responseStatusText', sanitizedResponse.statusText);
sanitizedResponse.responseTime = responseTime;
vcm.set('responseTime', responseTime);
// Mark this as @deprecated. which should use __response_status__
vcm.set('code', sanitizedResponse.status);
vcm.set('headers()', sanitizedResponse.headers);
vcm.set('responseTime', responseTime);
// For storing results based on step variable to use for chaining
if (step.var) {
if (Array.isArray(step.var)) {
step.var.forEach(
(obj: Record<string, string> | { key: string; value: string }) => {
if ('key' in obj && 'value' in obj) {
const { key, value } = obj;
let resolvedValue: any;
// Handle system variable style references (like responseBody.id)
if (typeof value === 'string' && value.includes('.')) {
// Extract system variable name and property path
const [systemVar, ...pathParts] = value.split('.');
const path = pathParts.join('.');
const isKnownSystemVar = defaultSystemVars.includes(systemVar);
// Get the base value from VCM
const baseValue = vcm.get(systemVar);
if (baseValue === undefined) {
// Different log message based on whether it's a known system variable
const message = isKnownSystemVar
? `System variable "${systemVar}" exists but has no value yet`
: `Unknown system variable "${systemVar}"`;
LogWrapper.logWarn(
'0003',
`Variable resolution warning: ${message}`,
);
resolvedValue = undefined;
} else {
const unwrappedValue =
baseValue?.value !== undefined
? baseValue.value
: baseValue;
resolvedValue = path
? _get(unwrappedValue, path)
: unwrappedValue;
}
} else if (
typeof value === 'string' &&
defaultSystemVars.includes(value)
) {
const baseValue = vcm.get(value);
if (baseValue === undefined) {
const message = `Unknown system variable "${value}"`;
LogWrapper.logWarn(
'0003',
`Variable resolution warning: ${message}`,
);
resolvedValue = undefined;
} else {
resolvedValue =
baseValue?.value !== undefined
? baseValue.value
: baseValue;
}
} else {
resolvedValue = null;
}
vcm.set(key, resolvedValue);
} else {
const [key, jsonPath] = Object.entries(obj)[0];
vcm.set(key, _get(parsedResponseData, jsonPath));
}
},
);
} else {
vcm.set(step.var, parsedResponseData);
}
}
}
async execute(step: Request, contextId: string): Promise<any> {
const {
headers: stepHeaders,
auth,
payload,
settings,
endpoint: url,
parameters,
...rest
} = step;
if (!url) {
throw new Error('Endpoint is required');
}
const start = Date.now();
let data;
const headers = {
...this.constructRecord(stepHeaders),
...this.constructAuthHeaders(contextId, auth),
};
try {
data = this.constructData(this.constructRecord(stepHeaders), payload);
} catch (error: any) {
const errorResponse = {
status: 0,
statusText: 'Invalid file path error',
headers: {},
data: { error: error?.message || 'Invalid file path error' },
error: error,
};
await this.setValues(
start,
errorResponse,
{
...rest,
headers,
},
contextId,
step,
);
throw error;
}
const stepRequest = {
...rest,
url,
headers,
validateSSL: settings?.sslVerification,
data,
params: this.constructRecord(parameters),
};
delete stepRequest.assertions;
let request;
try {
// Resolve variables in the request
request = VCM.resolve(contextId, stepRequest);
} catch (error: any) {
// Create a structured error response for variable resolution failures
const errorResponse = {
status: 0,
statusText: 'Variable Resolution Error',
headers: {},
data: { error: error?.message || 'Unknown variable resolution error' },
error: error,
};
await this.setValues(start, errorResponse, stepRequest, contextId, step);
throw error;
}
try {
const isFormDataAvailable: boolean = this.checkIfFormData(request);
const response = await this.httpClient.request(
request,
isFormDataAvailable,
);
await this.setValues(start, response, request, contextId, step);
return response;
} catch (error) {
const err = error as any;
const response = err.response || err;
await this.setValues(start, response, request, contextId, step);
throw error;
}
}
private constructRecord(
data?: Array<{ key: string; value: any }>,
): Record<string, any> {
const result: Record<string, any> = {};
for (const { key, value } of data ?? []) {
result[key] = value;
}
return result;
}
private constructData(headers: any, payload?: Payload) {
if (!payload) {
return;
}
const { raw, urlEncodedFormData, formData } = payload;
if (raw) {
// Prioritize these types in this order
const order: (keyof typeof raw)[] = ['json', 'xml', 'js', 'html'];
for (const key of order) {
const value = raw[key];
if (value) return value;
}
} else if (urlEncodedFormData) {
return qs.stringify(this.constructRecord(urlEncodedFormData));
} else if (formData) {
/* eslint-disable @typescript-eslint/no-unused-expressions */
let openAPIVersion = 2;
const contentType =
headers['Content-Type']?.toLowerCase() ||
headers['content-type']?.toLowerCase();
if (['application/octet-stream', 'image/png'].includes(contentType)) {
openAPIVersion = 3;
}
const form = openAPIVersion === 3 ? new FormDataNode() : new FormData();
const uploadedFiles = uploadedFileModel.getAllUploadedFiles();
const uploadedFileKey = new Set<string>();
// Add uploaded files to the form and track their keys
if (Array.isArray(uploadedFiles) && uploadedFiles.length > 0) {
uploadedFiles.forEach((ele: any) => {
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(ele.value)) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: FormData in browser doesn't support Buffer, but Node.js `form-data` does
openAPIVersion === 3
? form.append(ele.fileName, ele.value, ele.fileName)
: form.append('file', ele.value);
uploadedFileKey.add(ele.fileName);
}
});
}
// Add regular form fields, avoiding duplicates with uploaded files
if (Array.isArray(formData) && formData.length > 0) {
formData.forEach(({ key, value }) => {
if (!uploadedFileKey.has(key) && key !== 'file') {
form.append(key, value);
}
});
/* eslint-enable @typescript-eslint/no-unused-expressions */
}
return form;
}
return;
}
private constructAuthHeaders(
contextId: string,
auth?: AuthOptions,
): Record<string, string> {
const headers: Record<string, string> = {};
if (auth) {
if (auth.bearerToken) {
headers['Authorization'] = `Bearer ${auth.bearerToken}`;
return headers;
}
if (auth.basicAuth) {
const { username = '', password = '' } = auth.basicAuth;
let basicString = `${username}:${password}`;
try {
// Resolve any variables in the username:password string
basicString = VCM.resolve(contextId, basicString);
} catch (error) {
// If variable resolution fails, use the original string
// This allows basic auth to work even if variables are not defined
LogWrapper.logWarn(
'0004',
`Failed to resolve variables in basic auth credentials: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
const encoded = Buffer.from(basicString).toString('base64');
headers['Authorization'] = `Basic ${encoded}`;
return headers;
}
}
return headers;
}
private checkIfFormData(request: any): boolean {
const data = request.data;
const contentType =
request.headers?.['Content-Type'] ||
request.headers?.['content-type'] ||
'';
return (
data instanceof FormData ||
data instanceof FormDataNode ||
contentType.includes('multipart/form-data') ||
contentType.includes('application/octet-stream') ||
contentType.includes('image/png')
);
}
}