UNPKG

@apistudio/apim-cli

Version:

CLI for API Management Products

511 lines (462 loc) 14.2 kB
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 };