@apistudio/apim-cli
Version:
CLI for API Management Products
373 lines (372 loc) • 15.6 kB
JavaScript
/**
* Copyright IBM Corp. 2024, 2025
*/
import { AxiosClient } from './axios-client.js';
import { VCM } from '../variable-context-manager/context-manager.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(httpClient = new AxiosClient()) {
this.httpClient = httpClient;
}
/**
* Remove properties that cause circular references from response/request objects
* @param obj The object to sanitize
* @returns A sanitized copy of the object
*/
removeCircularProperties(obj) {
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 = {};
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, response, request, contextId, step) {
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 = 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) => {
if ('key' in obj && 'value' in obj) {
const { key, value } = obj;
let resolvedValue;
// 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, contextId) {
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) {
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) {
// 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 = 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;
const response = err.response || err;
await this.setValues(start, response, request, contextId, step);
throw error;
}
}
constructRecord(data) {
const result = {};
for (const { key, value } of data ?? []) {
result[key] = value;
}
return result;
}
constructData(headers, payload) {
if (!payload) {
return;
}
const { raw, urlEncodedFormData, formData } = payload;
if (raw) {
// Prioritize these types in this order
const order = ['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();
// Add uploaded files to the form and track their keys
if (Array.isArray(uploadedFiles) && uploadedFiles.length > 0) {
uploadedFiles.forEach((ele) => {
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;
}
constructAuthHeaders(contextId, auth) {
const headers = {};
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;
}
checkIfFormData(request) {
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'));
}
}