@apistudio/apim-cli
Version:
CLI for API Management Products
743 lines (639 loc) • 22.4 kB
text/typescript
/**
* Copyright IBM Corp. 2024, 2025
*/
import axios from 'axios';
import { RestHandler } from '../../../src/engine/protocol/rest-handler.js';
import { AxiosClient } from '../../../src/engine/protocol/axios-client.js';
import { VCM } from '../../../src/engine/variable-context-manager/context-manager.js';
import { Request } from '../../../src/schemas/test.schema.js';
import { VariableContext } from '../../../src/engine/variable-context-manager/variable-context.js';
import fs from 'fs';
// Mock the LogWrapper
jest.mock('../../../src/service/log-wrapper.js', () => ({
LogWrapper: {
logWarn: jest.fn(),
logInfo: jest.fn(),
logError: jest.fn(),
logDebug: jest.fn(),
},
}));
jest.mock('axios');
const mockSetVariable = jest.fn();
const mockedAxios = axios as any as jest.Mock;
jest.mock('fs');
jest.spyOn(VCM, 'getContext').mockReturnValue({
setVariable: mockSetVariable,
set: mockSetVariable,
} as unknown as VariableContext);
describe('RestHandler with AxiosClient', () => {
const sessionId = 'restHandlerContext';
beforeAll(() => {
VCM.createContext('restHandlerContext').setVariable('user', {
id: '42',
name: 'Bob',
});
VCM.createContext('restHandlerContext')?.setVariable(
'token',
'secrettoken',
);
VCM.createContext('restHandlerContext')?.setVariable('account', 'alice');
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should interpolate and make a GET request', async () => {
mockedAxios.mockResolvedValueOnce({ data: { ok: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
resource: '${user.id}',
endpoint: 'https://example.com/user/${user.id}',
headers: [{ key: 'Authorization', value: 'Bearer ${token}' }],
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://example.com/user/42',
headers: expect.objectContaining({
Authorization: 'Bearer secrettoken',
}),
method: 'GET',
}),
);
expect(result.data).toEqual({ ok: true });
});
it('should throw error for missing variable when throwOnMissing is true', async () => {
mockedAxios.mockResolvedValueOnce({ data: { success: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
resource: '${notfound}',
endpoint: 'https://api.com/${notfound}',
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
url: 'https://api.com/',
}),
);
expect(result.data).toEqual({ success: true });
});
it('should handle POST request with JSON body', async () => {
mockedAxios.mockResolvedValueOnce({ data: { success: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'POST',
resource: 'create',
endpoint: 'https://api.com/create',
payload: { raw: { json: '{ name: "${user.name}" }' } },
headers: [{ key: 'ContentType', value: 'application/json' }],
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
url: 'https://api.com/create',
data: expect.stringContaining('{ name: "Bob" }'),
headers: expect.objectContaining({
ContentType: 'application/json',
}),
}),
);
expect(result.data).toEqual({ success: true });
});
it('should handle POST request with xml body', async () => {
mockedAxios.mockResolvedValueOnce({ data: { success: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'POST',
resource: 'create',
endpoint: 'https://api.com/create',
payload: { raw: { xml: '{ name: "${user.name}" }' } },
headers: [{ key: 'ContentType', value: 'application/xml' }],
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
url: 'https://api.com/create',
data: expect.stringContaining('{ name: "Bob" }'),
headers: expect.objectContaining({
ContentType: 'application/xml',
}),
}),
);
expect(result.data).toEqual({ success: true });
});
it('should handle POST request with js body', async () => {
mockedAxios.mockResolvedValueOnce({ data: { success: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'POST',
resource: 'create',
endpoint: 'https://api.com/create',
payload: { raw: { js: 'console.log("${user.name}")' } },
headers: [{ key: 'ContentType', value: 'text/plain' }],
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
url: 'https://api.com/create',
data: expect.stringContaining('console.log("Bob")'),
headers: expect.objectContaining({
ContentType: 'text/plain',
}),
}),
);
expect(result.data).toEqual({ success: true });
});
it('should handle POST request with html body', async () => {
mockedAxios.mockResolvedValueOnce({ data: { success: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'POST',
resource: 'create',
endpoint: 'https://api.com/create',
payload: { raw: { js: '<html><body>${user.name}</body></html>' } },
headers: [{ key: 'ContentType', value: 'text/plain' }],
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
url: 'https://api.com/create',
data: expect.stringContaining('<html><body>Bob</body></html>'),
headers: expect.objectContaining({
ContentType: 'text/plain',
}),
}),
);
expect(result.data).toEqual({ success: true });
});
it('should send form-urlencoded data correctly', async () => {
mockedAxios.mockResolvedValueOnce({ data: { status: 'ok' } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'POST',
resource: 'login',
endpoint: 'https://api.com/login',
payload: {
urlEncodedFormData: [
{ key: 'username', value: 'test' },
{ key: 'password', value: '123' },
],
},
headers: [
{ key: 'ContentType', value: 'application/x-www-form-urlencoded' },
],
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
ContentType: 'application/x-www-form-urlencoded',
}),
data: expect.stringContaining('username=test&password=123'), // pragma: allowlist secret
}),
);
expect(result.data).toEqual({ status: 'ok' });
});
it('should handle XML response parsing', async () => {
const xmlResponse = '<root><user>John</user><id>123</id></root>';
mockedAxios.mockResolvedValueOnce({
data: xmlResponse,
headers: { 'content-type': 'application/xml' },
status: 200,
statusText: 'OK',
});
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
endpoint: 'https://api.com/xml',
resource: 'xml',
};
await handler.execute(step, sessionId);
// Verify XML was parsed
expect(mockSetVariable).toHaveBeenCalledWith(
'xml()',
expect.objectContaining({
root: expect.objectContaining({
user: 'John',
id: '123',
}),
}),
);
expect(mockSetVariable).toHaveBeenCalledWith(
'responseBody',
expect.objectContaining({
root: expect.objectContaining({
user: 'John',
id: '123',
}),
}),
);
});
it('should handle file not found error in formData', async () => {
// Mock fs.existsSync to simulate file not found
(fs.existsSync as jest.Mock).mockReturnValue(false);
// Create a custom error to be thrown when file is not found
const fileNotFoundError = new Error(
'File not found: /non/existent/path.txt',
);
// Mock fs.readFileSync to throw the error
(fs.readFileSync as jest.Mock).mockImplementation(() => {
throw fileNotFoundError;
});
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'POST',
endpoint: 'https://api.com/upload',
resource: 'upload',
payload: {
formData: [
{ key: 'file', value: '/non/existent/path.txt', type: 'file' },
],
},
};
// Clear previous mock calls
mockSetVariable.mockClear();
// Execute and expect it to throw
await expect(handler.execute(step, sessionId)).rejects.toThrow();
// Check that the error response was set in the context
expect(mockSetVariable).toHaveBeenCalledWith('request', expect.anything());
// The error response should be set at some point
const calls = mockSetVariable.mock.calls;
const responseCall = calls.find((call) => call[0] === 'response');
expect(responseCall).toBeDefined();
if (responseCall) {
const responseArg = responseCall[1];
expect(responseArg).toEqual(undefined);
}
});
it('should handle variable resolution errors', async () => {
// Mock VCM.resolve to throw an error
jest.spyOn(VCM, 'resolve').mockImplementationOnce(() => {
throw new Error('Variable not found: ${missing}');
});
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
endpoint: 'https://api.com/${missing}',
resource: 'error',
};
await expect(handler.execute(step, sessionId)).rejects.toThrow(
'Variable not found: ${missing}',
);
// Verify error response is set in context
expect(mockSetVariable).toHaveBeenCalledWith(
'response',
expect.objectContaining({
status: 0,
statusText: 'Variable Resolution Error',
data: expect.objectContaining({
error: 'Variable not found: ${missing}',
}),
}),
);
});
it('should handle HTTP request failures', async () => {
// Mock axios to throw an error with a response
const errorResponse = {
status: 500,
statusText: 'Internal Server Error',
headers: { 'content-type': 'application/json' },
data: { error: 'Server error' },
};
const error = new Error('Request failed');
(error as any).response = errorResponse;
mockedAxios.mockRejectedValueOnce(error);
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
endpoint: 'https://api.com/error',
resource: 'error',
};
await expect(handler.execute(step, sessionId)).rejects.toThrow(
'Request failed',
);
// Verify error response is set in context
expect(mockSetVariable).toHaveBeenCalledWith('response', errorResponse);
expect(mockSetVariable).toHaveBeenCalledWith('responseStatus', 500);
});
it('should handle HTTP request failures without response object', async () => {
// Mock axios to throw an error without a response
const error = new Error('Network error');
mockedAxios.mockRejectedValueOnce(error);
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
endpoint: 'https://api.com/network-error',
resource: 'error',
};
await expect(handler.execute(step, sessionId)).rejects.toThrow(
'Network error',
);
// Verify error itself is set as response in context
expect(mockSetVariable).toHaveBeenCalledWith('response', error);
});
it('should handle SSL verification settings', async () => {
mockedAxios.mockResolvedValueOnce({ data: { success: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
endpoint: 'https://api.com/secure',
resource: 'secure',
settings: {
sslVerification: false,
},
};
await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
httpsAgent: expect.objectContaining({
options: expect.objectContaining({
rejectUnauthorized: false,
}),
}),
}),
);
});
it('should attach basic auth headers', async () => {
mockedAxios.mockResolvedValueOnce({ data: { auth: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
resource: 'secure',
endpoint: 'https://api.com/secure',
auth: {
basicAuth: {
username: 'foo',
password: '<PASSWORD>', // pragma: allowlist secret
},
},
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Basic Zm9vOjxQQVNTV09SRD4=',
}),
}),
);
expect(result.data).toEqual({ auth: true });
});
it('should interpolate nested headers and query params', async () => {
mockedAxios.mockResolvedValueOnce({ data: { query: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
resource: 'search',
endpoint: 'https://api.com/search',
headers: [{ key: 'X-User', value: '${user.name}' }],
parameters: [
{ key: 'id', value: '${user.id}' },
{ key: 'name', value: '${user.name}' },
],
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
params: { id: '42', name: 'Bob' },
headers: expect.objectContaining({ 'X-User': 'Bob' }),
}),
);
expect(result.data).toEqual({ query: true });
});
it('should override default headers with interpolated values', async () => {
mockedAxios.mockResolvedValueOnce({ data: { overridden: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
resource: 'data',
endpoint: 'https://api.com/data',
auth: {
bearerToken: '${token}',
},
headers: [{ key: 'Content-Type', value: 'application/json' }],
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer secrettoken',
'Content-Type': 'application/json',
}),
}),
);
expect(result.data).toEqual({ overridden: true });
});
it('should interpolate each item in an array', () => {
const arr = ['User ID: ${user.id}', 'Token: ${token}'];
const result = VCM.resolve('restHandlerContext', arr);
expect(result).toEqual(['User ID: 42', 'Token: secrettoken']);
});
it('should throw error if HTTP url is missing', async () => {
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
resource: 'nomethod',
// enpoint is missing here
};
await expect(handler.execute(step, sessionId)).rejects.toThrow(
'Endpoint is required',
);
});
it('should handle empty payload without throwing', async () => {
mockedAxios.mockResolvedValueOnce({ data: { ok: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'POST',
resource: 'empty',
endpoint: 'https://api.com',
payload: undefined,
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
}),
);
expect(result.data).toEqual({ ok: true });
});
it('should send multipart/form-data using FormData payload', async () => {
mockedAxios.mockResolvedValueOnce({ data: { uploaded: true } });
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'POST',
endpoint: 'https://api.com',
resource: 'upload',
headers: [{ key: 'ContentType', value: 'application/json' }],
payload: {
formData: [
{ key: 'file', value: 'mock-file-content' },
{ key: 'description', value: 'test upload' },
],
},
};
const result = await handler.execute(step, sessionId);
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.any(FormData),
}),
);
expect(result.data).toEqual({ uploaded: true });
});
it('should handle file uploads in FormData', async () => {
// Clear previous mock calls
mockSetVariable.mockClear();
mockedAxios.mockClear();
// Mock file existence check
(fs.existsSync as jest.Mock).mockReturnValue(true);
// Mock file reading
(fs.readFileSync as jest.Mock).mockReturnValue(
Buffer.from('test file content'),
);
// Mock axios response
mockedAxios.mockResolvedValueOnce({
data: { fileUploaded: true },
status: 200,
statusText: 'OK',
headers: {},
});
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'POST',
endpoint: 'https://api.com/upload',
resource: 'upload',
payload: {
formData: [
{ key: 'file', value: '/path/to/test.txt', type: 'file' },
{ key: 'description', value: 'File upload test' },
],
},
};
const result = await handler.execute(step, sessionId);
// Verify the response
expect(result.data).toEqual({ fileUploaded: true });
// Verify that the request was made with FormData
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
url: 'https://api.com/upload',
}),
);
// Verify that response variables were set
expect(mockSetVariable).toHaveBeenCalledWith('responseStatus', 200);
expect(mockSetVariable).toHaveBeenCalledWith('responseBody', {
fileUploaded: true,
});
});
it('should store selected keys in context when step.var is an array', async () => {
const userId = 123;
const address = {
street: '123 Main St',
city: 'New York',
state: 'NY',
};
const address1 = {
street: '456 Elm St',
city: 'Florida',
state: 'US',
};
const address2 = {
street: '789 Oak St',
city: 'Florida',
state: 'FL',
};
const user = {
userId,
name: 'Bob',
address: [address, address1],
};
const response = [
user,
{
userId: 456,
name: 'Alice',
address: [address2],
},
];
mockedAxios.mockResolvedValueOnce({
data: response,
});
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
endpoint: 'https://api.com',
resource: 'users',
var: [
{ id: 'userId' },
{ userId: '[0].userId' },
{ address: '[1].address' },
{ firstAddress: '[0].address[0]' },
{ response1: '[0]' },
{ response1_name: '[0].name' },
{ response1_address2: '[0].address[1]' },
],
};
const result = await handler.execute(step, sessionId);
expect(mockSetVariable).toHaveBeenCalledWith('id', undefined);
expect(mockSetVariable).toHaveBeenCalledWith('userId', userId);
expect(mockSetVariable).toHaveBeenCalledWith('address', [address2]);
expect(mockSetVariable).toHaveBeenCalledWith('firstAddress', address);
expect(mockSetVariable).toHaveBeenCalledWith('response1', user);
expect(mockSetVariable).toHaveBeenCalledWith('response1_name', user.name);
expect(mockSetVariable).toHaveBeenCalledWith(
'response1_address2',
address1,
);
expect(result).toMatchObject(expect.objectContaining({ data: response }));
});
it('should store full response in context when step.var is a string', async () => {
mockedAxios.mockResolvedValueOnce({
data: { token: 'secrettoken', user: 'alice' },
});
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
endpoint: 'https://api.com',
resource: 'login',
var: 'loginResponse',
};
const result = await handler.execute(step, sessionId);
expect(mockSetVariable).toHaveBeenCalledWith(
'loginResponse',
expect.objectContaining({ token: 'secrettoken', user: 'alice' }),
);
expect(result.data).toEqual({ token: 'secrettoken', user: 'alice' });
});
it('should handle system variables with undefined values', async () => {
mockedAxios.mockResolvedValueOnce({ data: { success: true } });
// Mock VCM.getContext to return a context with specific behavior for system variables
const mockContext = {
set: mockSetVariable,
get: jest.fn().mockImplementation((key) => {
if (key === 'responseBody') {
return undefined; // Known system var with undefined value
}
return null;
}),
};
jest.spyOn(VCM, 'getContext').mockReturnValue(mockContext as any);
const handler = new RestHandler(new AxiosClient());
const step: Request = {
method: 'GET',
endpoint: 'https://api.com/test',
resource: 'test',
var: [
{ key: 'responseValue', value: 'responseBody.data' },
{ key: 'unknownValue', value: '__unknown_var__.data' },
],
};
await handler.execute(step, sessionId);
// Verify that the logger was used for both cases
expect(mockSetVariable).toHaveBeenCalledWith('responseValue', undefined);
expect(mockSetVariable).toHaveBeenCalledWith('unknownValue', undefined);
});
});