@apistudio/apim-cli
Version:
CLI for API Management Products
511 lines (462 loc) • 14.2 kB
text/typescript
import { MigrateTestOptionsModel } from "../model/studio/command-options/migrate-test-options-model.js";
import { DebugManager } from "../debug/debug-manager.js";
import fs from "fs";
import path from "path";
import { parse } from "yaml";
import {
AtmConfig,
AtmInfo,
AtmTestConfig,
TestStep,
TestStepType,
} from "../model/atm-test-model.js";
import { Assert } from "@apic/api-model/test/common/Assert.js";
import { Test, Test_Request, Test_Payload } from "@apic/api-model/test/Test.js";
import yaml from "js-yaml";
import { showError, showInfo } from "../helpers/common/message-helper.js";
import {
MIGRATION_STARTED,
MIGRATION_COMPLETED,
} from "../constants/message-constants.js";
import { ASSERTION, ENVIRONMENT, TEST } from "../constants/app-constants.js";
import { Environment } from "@apic/api-model/test/Environment.js";
import { Assertion } from "@apic/api-model/test/Assertion.js";
const namespace = "default";
const apiVersion = "api.webmethods.io/beta";
const version = "1.0.0";
export const setupDebugManager = (debug: boolean): DebugManager => {
const debugManager = DebugManager.getInstance();
debugManager.setDebugEnabled(debug);
return debugManager;
};
const isYamlFile = (path: string) => {
return path.endsWith(".yml") || path.endsWith(".yaml");
};
const readYamlFile = (filePath: string): AtmTestConfig | null => {
try {
const content = fs.readFileSync(filePath, "utf8");
const test = parse(content) as AtmTestConfig;
if (!Array.isArray(test.steps) || test.steps.length === 0) {
throw new Error("Missing or empty 'steps' in test");
}
return test;
} catch (error) {
showError(`Failed to read YAML file: ${filePath}`);
return null;
}
};
const readYamlFromPath = (
inputPath: string
): Array<{ fileName: string; test: AtmTestConfig }> => {
const stat = fs.statSync(inputPath);
let result: Array<{ fileName: string; test: AtmTestConfig }> = [];
if (stat.isDirectory()) {
const files = fs.readdirSync(inputPath);
for (const file of files) {
if (isYamlFile(file)) {
const fullPath = path.join(inputPath, file);
const test = readYamlFile(fullPath);
const fileName = path.basename(fullPath, path.extname(fullPath));
if (test) result.push({ fileName, test });
}
}
} else if (stat.isFile() && isYamlFile(inputPath)) {
const fileName = path.basename(inputPath, path.extname(inputPath));
const test = readYamlFile(inputPath);
if (test) result.push({ fileName, test });
} else {
showError(`Unsupported path type: ${inputPath}`);
}
return result;
};
const mapToKeyValue = (
headers?: Record<string, string> | undefined
): Array<{ key: string; value: string }> | undefined => {
if (!headers || typeof headers !== "object") return undefined;
return Object.entries(headers).map(([key, value]) => ({
key,
value: mapVariables(value) ?? "",
}));
};
const mapBodyToPayload = (mode?: string, body?: any): Test_Payload => {
const payload: Test_Payload = {};
if (!mode || body == null) return payload;
payload.raw = {
['json']: mode === "json" ? `${JSON.stringify(body)}` : String(body),
};
return payload;
};
// Map .[0] → .0
const normalizeArrayFormat = (str?: string): string | undefined => {
if (!str || typeof str !== "string") return str;
return str.replace(/\.\[(\d+)\]/g, ".$1");
};
// Map {{ variable }} to ${variable} for studio
const mapVariables = (str?: string): string | undefined => {
if (!str || typeof str !== "string") return str;
return str.replace(
/{{\s*(.*?)\s*}}/g,
(_, expression) => `\${${normalizeArrayFormat(expression)}}`
);
};
const mapExpressions = (
input?: string | number,
variableName?: string
): string | undefined => {
const str = input?.toString();
if (!str) return str;
switch (true) {
case str.endsWith("_response_statusCode"):
return "${code}";
case str.includes("_response_header_"):
return str
.replace(`${variableName}_response_header_`, "headers().")
.toLocaleLowerCase();
default: {
if (!variableName) return str;
return `\${${normalizeArrayFormat(str)}}`;
}
}
};
const mapAssertions = (
step: TestStep,
ifExpression?: string,
variableName?: string
): Assert | undefined => {
const {
type,
stoponfail: stopOnFail,
value: rawValue,
values: rawValues,
expression: rawExpression,
expression1: rawExpression1,
expression2: rawExpression2,
} = step;
const expression = mapExpressions(rawExpression, variableName);
const expression1 = mapExpressions(rawExpression1, variableName);
const expression2 = mapExpressions(rawExpression2, variableName);
const value = mapVariables(rawValue);
const values = rawValues?.map(mapVariables);
const ifExpressionVariable = mapExpressions(ifExpression, variableName);
const base = {
...(stopOnFail?.toString() === "true" ? { stopOnFail: true } : {}),
...(ifExpressionVariable ? { if: ifExpressionVariable } : {}),
};
switch (type) {
case TestStepType.AssertEquals:
case TestStepType.AssertCompares:
return {
...base,
action: "equals",
name: `${type} => equals`,
key: type === TestStepType.AssertCompares ? expression1 : expression,
value: type === TestStepType.AssertCompares ? expression2 : value,
} as Assert;
case TestStepType.AssertGreater:
return {
...base,
action: "greaterThan",
name: `${type} => greaterThan`,
key: expression,
value,
} as Assert;
case TestStepType.AssertLess:
return {
...base,
action: "lessThan",
name: `${type} => lessThan`,
key: expression,
value,
} as Assert;
case TestStepType.AssertIs:
return {
...base,
action: "type",
name: `${type} => type`,
key: expression,
value,
} as Assert;
case TestStepType.AssertContains:
return {
...base,
action: "include",
name: `${type} => include`,
key: expression,
value: value,
} as Assert;
case TestStepType.AssertExists:
return {
...base,
action: "haveProperty",
name: `${type} => haveProperty`,
key: expression,
value,
} as Assert;
case TestStepType.AssertIn:
return {
...base,
action: "include",
name: `${type} => include`,
key: values,
value: expression,
} as Assert;
case TestStepType.AssertMatches:
return {
...base,
action: "matches",
name: `${type} => matches`,
key: expression,
value,
} as Assert;
default:
return;
}
};
const splitUrlIntoBaseAndPath = (url?: string): Array<string> => {
if (!url) return ["", ""];
const lastSlashIndex = url?.lastIndexOf("/");
if (lastSlashIndex === -1 || lastSlashIndex === url.length - 1) {
// No slash found or trailing slash with nothing after
return [url, ""];
}
return [url.substring(lastSlashIndex), url.substring(0, lastSlashIndex)];
};
const getRequests = (
outputDir: string,
fileName: string,
steps: Array<TestStep>
): Array<Test_Request> => {
if (!steps?.length) return [];
const requests: Array<Test_Request> = [];
let currentRequest: Test_Request | undefined;
for (const step of steps) {
const {
type,
method,
var: variableName,
body,
mode,
headers,
url,
params,
steps: subSteps,
expression,
} = step;
if (type === TestStepType.Request) {
const [resource, endpoint] = splitUrlIntoBaseAndPath(mapVariables(url));
currentRequest = {
method,
resource,
var: variableName,
...(body && mode ? { payload: mapBodyToPayload(mode, body) } : {}),
...(headers ? { headers: mapToKeyValue(headers) } : {}),
...(params ? { parameters: mapToKeyValue(params) } : {}),
assertions: { expressions: [] },
endpoint,
} as Test_Request;
requests.push(currentRequest);
} else {
if (!currentRequest) {
showError(`Assertion step without a preceding request`);
continue;
}
const targets = type === TestStepType.If ? subSteps ?? [] : [step];
for (const sub of targets) {
const assertion = mapAssertions(
sub,
type === TestStepType.If ? expression : undefined,
currentRequest.var
);
if (assertion) currentRequest.assertions?.expressions?.push(assertion);
}
}
}
// Iterate requests and replace assertion with Ref
for (let i = 0; i < requests.length; i++) {
const request = requests[i];
const assertionRef = getAssertionRef(
outputDir,
fileName,
i,
request.assertions?.expressions
);
request.assertions = {
$ref: assertionRef,
};
}
return requests;
};
const getAssertionRef = (
outputDir: string,
fileName: string,
index: number,
spec?: Array<Assert>
): string => {
const suffix = index > 0 ? `_${index}` : "";
const name = `assertion${suffix}_${formatTitle(fileName)}`;
let assertionRef = getRef(namespace, name, version);
const assertion: Assertion = {
kind: ASSERTION,
apiVersion,
metadata: generateMetadata(name),
spec,
};
try {
writeFile(assertion, outputDir, name);
} catch {
showError(`Failed to write assertion for ${fileName}`);
}
return assertionRef;
};
const formatTitle = (title?: string): string =>
title?.toLowerCase()?.replace(/\s/g, "") ?? "untitled";
const getRef = (namespace: string, name: string, version: string) =>
`${namespace}:${name}:${version}`;
const generateMetadata = (name: string) => ({
name,
namespace,
version,
tags: [],
});
const getEnvironmentRefs = (
outputDir: string,
fileName: string,
configs: AtmConfig,
info: AtmInfo
): Array<string> => {
const { version: atmVersion } = info;
const environmentRefs: Array<string> = [];
const environments: Record<
string,
Array<{ key: string; value: string }>
> = {};
if (atmVersion?.toString() == "2") {
const { globalVariables, inputs } = configs;
for (const input of inputs) {
const [label, inputVariables] = Object.entries(input)[0];
environments[label] = [
...(mapToKeyValue(inputVariables) ?? []),
...(mapToKeyValue(globalVariables) ?? []),
];
}
} else {
environments["default"] = [...(mapToKeyValue(configs) ?? [])];
}
for (const [label, variables] of Object.entries(environments)) {
const name = `environment_${formatTitle(label)}_${formatTitle(fileName)}`;
const environment: Environment = {
kind: ENVIRONMENT,
apiVersion,
metadata: generateMetadata(name),
spec: {
variables: variables.map((variable) => ({
...variable,
isSecret: false,
})),
},
};
try {
writeFile(environment, outputDir, name);
environmentRefs.push(getRef(namespace, name, version));
} catch {
showError(
`Failed to write ${label} variables as environment for ${fileName}`
);
}
}
return environmentRefs;
};
const generateTest = (
{ info, steps, configs }: AtmTestConfig,
outputDir: string,
fileName: string,
debugManager: DebugManager
): void => {
const environmentRefs = getEnvironmentRefs(
outputDir,
fileName,
configs,
info ?? {}
);
if (debugManager.isDebugEnabled()) {
showInfo(
`Generated following EnvironmentRefs: ${environmentRefs} for ${fileName}`
);
}
const request = getRequests(outputDir, fileName, steps ?? []);
if (debugManager.isDebugEnabled()) {
showInfo(`Generated ${request.length} for ${fileName}`);
}
const test: Test = {
kind: TEST,
apiVersion,
metadata: generateMetadata(`Test for ${formatTitle(fileName)}`),
spec: {
api: {
$endpoint: "// Replace with a valid endpoint",
},
environment: {
$ref: environmentRefs[0], //map only one environment for current support
},
request,
},
};
if (debugManager.isDebugEnabled()) {
showInfo(`Generated test ${request.length} for ${fileName}`);
}
try {
writeFile(test, outputDir, fileName);
} catch {
showError(`Failed to write assertion for ${fileName}`);
}
};
const uniqueFilePath = (outputDir: string, fileName: string): string => {
const ext = ".yaml";
let candidate = path.join(outputDir, `${fileName}${ext}`);
let count = 1;
// Keep incrementing the suffix until we find a file name that doesn't exist
while (fs.existsSync(candidate)) {
candidate = path.join(outputDir, `${fileName}_${count}${ext}`);
count++;
}
return candidate;
};
const writeFile = (
file: Test | Environment | Assertion,
output: string,
fileName: string
) => {
const outputDir = path.resolve(output);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const filePath = uniqueFilePath(outputDir, fileName);
const yamlString = yaml.dump(file, { noRefs: true });
fs.writeFileSync(filePath, yamlString, "utf8");
};
const migrateTestAction = async (options: MigrateTestOptionsModel) => {
const { localDir, output, debug } = options;
const debugManager = setupDebugManager(Boolean(debug));
showInfo(MIGRATION_STARTED);
try {
if (debugManager.isDebugEnabled())
showInfo(`Started reading ATM test files from: ${localDir}`);
const atmTests = readYamlFromPath(localDir);
if (debugManager.isDebugEnabled())
showInfo(`Successfully read: ${atmTests.length} Atm test files`);
for (const { fileName, test } of atmTests) {
if (debugManager.isDebugEnabled()) {
showInfo(`Started generating tests for: ${fileName}`);
}
generateTest(test, output, fileName, debugManager);
if (debugManager.isDebugEnabled()) {
showInfo(`Completed generating tests for: ${fileName}`);
}
}
} catch (error) {
showError(String(error));
process.exit(1);
}
showInfo(MIGRATION_COMPLETED);
};
export { migrateTestAction };