UNPKG

@apistudio/apim-cli

Version:

CLI for API Management Products

373 lines (372 loc) 15.6 kB
/** * 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')); } }