lib/apiScenario/postmanCollectionGenerator.ts (515 lines of code) (raw):
import * as path from "path";
import * as fs from "fs";
import newman, { NewmanRunOptions, NewmanRunSummary } from "newman";
import { inject, injectable } from "inversify";
import { Collection, VariableScope } from "postman-collection";
import { inversifyGetInstance, TYPES } from "../inversifyUtils";
import { ReportGenerator as HtmlReportGenerator } from "../report/generateReport";
import { FileLoader } from "../swagger/fileLoader";
import { getApiVersionFromFilePath, getRandomString } from "../util/utils";
import {
OperationCoverageInfo,
RuntimeException,
TrafficValidationIssue,
TrafficValidationOptions,
unCoveredOperationsFormat,
} from "../swaggerValidator/trafficValidator";
import { LiveValidationIssue } from "../liveValidation/liveValidator";
import { setDefaultOpts } from "../swagger/loader";
import { logger } from "./logger";
import { ApiScenarioLoader, ApiScenarioLoaderOption } from "./apiScenarioLoader";
import { ApiScenarioRunner } from "./apiScenarioRunner";
import { generateMarkdownReportHeader } from "./markdownReport";
import { PostmanCollectionRunnerClient } from "./postmanCollectionRunnerClient";
import {
ApiScenarioTestResult,
NewmanReportValidator,
NewmanReportValidatorOption,
} from "./newmanReportValidator";
import { SwaggerAnalyzer, SwaggerAnalyzerOption } from "./swaggerAnalyzer";
import { EnvironmentVariables } from "./variableEnv";
import { parseNewmanSummary } from "./newmanReportParser";
import {
defaultCollectionFileName,
defaultEnvFileName,
defaultNewmanDir,
defaultNewmanReport,
defaultQualityReportFilePath,
} from "./defaultNaming";
import { DataMasker } from "./dataMasker";
import { Scenario, ScenarioDefinition } from "./apiScenarioTypes";
import { CLEANUP_FOLDER, PREPARE_FOLDER } from "./postmanHelper";
export interface PostmanCollectionGeneratorOption
extends ApiScenarioLoaderOption,
SwaggerAnalyzerOption {
fileRoot: string;
env: EnvironmentVariables;
outputFolder: string;
markdown?: boolean;
junit?: boolean;
html?: boolean;
runCollection: boolean;
generateCollection: boolean;
testProxy?: string;
testProxyAssets?: string;
calculateCoverage?: boolean;
skipValidation?: boolean;
savePayload?: boolean;
generateExample?: boolean;
runId?: string;
verbose?: boolean;
devMode?: boolean;
}
export const generateRunId = (): string => {
const today = new Date();
const yyyy = today.getFullYear().toString();
const MM = pad(today.getMonth() + 1, 2);
const dd = pad(today.getDate(), 2);
const hh = pad(today.getHours(), 2);
const mm = pad(today.getMinutes(), 2);
const id = getRandomString();
return yyyy + MM + dd + hh + mm + "-" + id;
};
function pad(number: number, length: number) {
let str = "" + number;
while (str.length < length) {
str = "0" + str;
}
return str;
}
interface PostmanCollectionRunnerOption extends PostmanCollectionGeneratorOption {
scenarioFile: string;
generator: PostmanCollectionGenerator;
}
@injectable()
class PostmanCollectionRunner {
private scenarioBaseFileLoader: FileLoader;
public environment: VariableScope;
private collection: Collection;
private baseEnvironment?: VariableScope;
private scenarioDef: ScenarioDefinition;
constructor(
@inject(TYPES.opts) private opt: PostmanCollectionRunnerOption,
private apiScenarioLoader: ApiScenarioLoader,
private fileLoader: FileLoader,
private dataMasker: DataMasker,
private swaggerAnalyzer: SwaggerAnalyzer
) {
this.opt.scenarioFile = this.fileLoader.resolvePath(this.opt.scenarioFile);
this.scenarioBaseFileLoader = new FileLoader({
fileRoot: path.dirname(this.opt.scenarioFile),
checkUnderFileRoot: false,
});
}
public static create(opt: PostmanCollectionRunnerOption) {
(opt as any).container = undefined;
return inversifyGetInstance(PostmanCollectionRunner, opt);
}
public async run(): Promise<Collection> {
this.scenarioDef = await this.apiScenarioLoader.load(
this.opt.scenarioFile,
this.opt.swaggerFilePaths
);
if (this.scenarioDef.scope.endsWith(".yaml") || this.scenarioDef.scope.endsWith(".yml")) {
const parentScenarioFile = this.scenarioBaseFileLoader.resolvePath(this.scenarioDef.scope);
if (!this.opt.generator.runnerMap.has(parentScenarioFile)) {
const runner = PostmanCollectionRunner.create({
...this.opt,
scenarioFile: parentScenarioFile,
});
await runner.run();
this.opt.generator.runnerMap.set(parentScenarioFile, runner);
}
this.baseEnvironment = this.opt.generator.runnerMap.get(parentScenarioFile)?.environment;
}
await this.doRun();
return this.collection;
}
public async cleanUp(skipCleanUp: boolean) {
if (this.opt.runCollection) {
try {
const foldersToRun = [];
if (
!skipCleanUp &&
this.collection.items.find((item) => item.name === CLEANUP_FOLDER, this.collection)
) {
foldersToRun.push(CLEANUP_FOLDER);
}
if (foldersToRun.length === 0) {
return;
}
const summary = await this.doRunCollection({
collection: this.collection,
environment: this.environment,
folder: foldersToRun,
reporters: "cli",
});
// todo add report
this.environment = summary.environment;
} catch (err) {
logger.error(`Error in running collection: ${err}`);
} finally {
if (skipCleanUp && this.scenarioDef.scope === "ResourceGroup") {
logger.warn(
`Notice: the resource group '${this.environment.get(
"resourceGroupName"
)}' was not cleaned up.`
);
}
}
}
}
public async generateReport() {
if (this.opt.calculateCoverage) {
const operationIdCoverageResult = this.swaggerAnalyzer.calculateOperationCoverage(
this.scenarioDef
);
logger.info(
`Operation coverage ${(operationIdCoverageResult.coverage * 100).toFixed(2) + "%"} (${
operationIdCoverageResult.coveredOperationNumber
}/${operationIdCoverageResult.totalOperationNumber})`
);
if (operationIdCoverageResult.uncoveredOperationIds.length > 0) {
logger.verbose("Uncovered operationIds: ");
logger.verbose(operationIdCoverageResult.uncoveredOperationIds);
}
}
if (this.opt.html && this.opt.runCollection) {
await this.generateHtmlReport();
}
}
private async doRun() {
await this.swaggerAnalyzer.initialize();
for (const it of this.scenarioDef.requiredVariables) {
if (this.opt.env[it] === undefined) {
throw new Error(
`Missing required variable '${it}', please set variable values in env.json.`
);
}
}
this.opt.runId = this.opt.runId || generateRunId();
if (this.opt.markdown) {
const reportExportPath = path.resolve(
this.opt.outputFolder,
`${defaultNewmanDir(this.scenarioDef.name, this.opt.runId!)}`
);
await this.fileLoader.writeFile(
path.join(reportExportPath, "report.md"),
generateMarkdownReportHeader()
);
}
await this.generateCollection();
if (this.opt.generateCollection) {
await this.writeCollectionToJson(this.scenarioDef.name, this.collection, this.environment);
}
if (this.opt.runCollection) {
try {
for (let i = 0; i < this.scenarioDef.scenarios.length; i++) {
const scenario = this.scenarioDef.scenarios[i];
const foldersToRun = [];
if (
i == 0 &&
this.collection.items.find((item) => item.name === PREPARE_FOLDER, this.collection)
) {
foldersToRun.push(PREPARE_FOLDER);
}
foldersToRun.push(scenario.scenario);
const reportExportPath = path.resolve(
this.opt.outputFolder,
`${defaultNewmanReport(this.scenarioDef.name, this.opt.runId!, scenario.scenario)}`
);
const summary = await this.doRunCollection({
collection: this.collection,
environment: this.environment,
folder: foldersToRun,
reporters: "cli",
});
await this.postRun(scenario, reportExportPath, summary.environment, summary);
this.environment = summary.environment;
}
} catch (err) {
logger.error(`Error in running collection: ${err}`);
}
}
}
private async generateCollection() {
const client = new PostmanCollectionRunnerClient({
scenarioFile: this.scenarioDef._filePath,
collectionName: this.scenarioDef.name,
runId: this.opt.runId!,
testProxy: this.opt.testProxy,
testProxyAssets: this.opt.testProxyAssets,
verbose: this.opt.verbose,
skipAuth: this.opt.devMode,
skipArmCall: this.opt.devMode,
skipLroPoll: this.opt.devMode,
jsonLoader: this.apiScenarioLoader.jsonLoader,
});
const runner = new ApiScenarioRunner({
jsonLoader: this.apiScenarioLoader.jsonLoader,
env: Object.assign(
{},
this.opt.env,
...(this.baseEnvironment?.values
?.filter((v) => !v.key?.startsWith("x_"))
.map((v) => ({ [v.key!]: v.value })) || [])
),
client: client,
});
await runner.execute(this.scenarioDef);
const [collection, environment] = client.outputCollection();
this.environment = environment;
this.collection = collection;
}
private async generateHtmlReport() {
const trafficValidationResult = new Array<TrafficValidationIssue>();
const reportExportPath = path.resolve(
this.opt.outputFolder,
`${defaultNewmanDir(this.scenarioDef.name, this.opt.runId!)}`
);
let providerNamespace;
for (const dir of fs.readdirSync(reportExportPath, { withFileTypes: true })) {
if (dir.isDirectory()) {
const report = JSON.parse(
await this.fileLoader.load(path.join(reportExportPath, dir.name, "report.json"))
) as ApiScenarioTestResult;
providerNamespace = report.providerNamespace;
for (const r of report.stepResult) {
const trafficValidationIssue: TrafficValidationIssue = {
errors: [
...(r.liveValidationResult?.requestValidationResult.errors ?? []),
...(r.liveValidationResult?.responseValidationResult.errors ?? []),
...(r.roundtripValidationResult?.errors ?? []),
],
specFilePath: r.specFilePath,
operationInfo: r.liveValidationResult?.requestValidationResult.operationInfo ?? {
operationId: r.operationId,
apiVersion: report.apiVersion ?? "unknown",
},
};
// mock
trafficValidationIssue.operationInfo!.position = {
line: 0,
column: 0,
};
if (this.opt.savePayload) {
trafficValidationIssue.payloadFilePath = r.payloadPath;
}
for (const runtimeError of r.runtimeError ?? []) {
trafficValidationIssue.errors?.push(this.convertRuntimeException(runtimeError));
}
if (r.liveValidationResult?.requestValidationResult.runtimeException) {
trafficValidationIssue.errors?.push(
this.convertRuntimeException(
r.liveValidationResult!.requestValidationResult.runtimeException
)
);
}
if (r.liveValidationResult?.responseValidationResult.runtimeException) {
trafficValidationIssue.errors?.push(
this.convertRuntimeException(
r.liveValidationResult!.responseValidationResult.runtimeException
)
);
}
trafficValidationResult.push(trafficValidationIssue);
}
}
}
const operationIdCoverageResult = this.swaggerAnalyzer.calculateOperationCoverageBySpec(
this.scenarioDef
);
const operationCoverageResult: OperationCoverageInfo[] = [];
operationIdCoverageResult.forEach((result, key) => {
let specPath = this.fileLoader.resolvePath(key);
if (process.env.REPORT_SPEC_PATH_PREFIX) {
specPath = path.join(
process.env.REPORT_SPEC_PATH_PREFIX,
specPath.substring(specPath.indexOf("specification"))
);
}
operationCoverageResult.push({
totalOperations: result.totalOperationNumber,
spec: specPath,
coverageRate: result.coverage,
apiVersion: getApiVersionFromFilePath(specPath),
unCoveredOperations: result.uncoveredOperationIds.length,
coveredOperations: result.totalOperationNumber - result.uncoveredOperationIds.length,
coveredOperationsList: result.coveredOperationIds.map((id) => {
return { operationId: id };
}),
validationFailOperations: new Set(
trafficValidationResult
.filter((it) => key.indexOf(it.specFilePath!) !== -1 && it.errors!.length > 0)
.map((t) => t.operationInfo?.operationId)
).size,
unCoveredOperationsList: result.uncoveredOperationIds.map((id) => {
return { operationId: id };
}),
unCoveredOperationsListGen: Object.values(
result.uncoveredOperationIds
.map((id) => {
return { operationId: id, key: id.split("_")[0] };
})
.reduce((res: { [key: string]: unCoveredOperationsFormat }, item) => {
/* eslint-disable no-unused-expressions */
res[item.key]
? res[item.key].operationIdList.push(item)
: (res[item.key] = {
operationIdList: [item],
});
/* eslint-enable no-unused-expressions */
return res;
}, {})
),
});
});
const options: TrafficValidationOptions = {
reportPath: path.resolve(reportExportPath, "report.html"),
overrideLinkInReport: false,
sdkPackage: providerNamespace,
markdownPath: this.opt.markdown ? path.resolve(reportExportPath, "report.md") : undefined,
};
const generator = new HtmlReportGenerator(
trafficValidationResult,
operationCoverageResult,
0,
options
);
await generator.generateHtmlReport();
}
private convertRuntimeException(runtimeException: RuntimeException): LiveValidationIssue {
const ret = {
code: runtimeException.code,
pathsInPayload: [],
severity: 1,
message: runtimeException.message,
jsonPathsInPayload: [],
schemaPath: "",
source: {
url: "",
position: {
column: 0,
line: 0,
},
},
};
return ret as LiveValidationIssue;
}
private async writeCollectionToJson(
collectionName: string,
collection: Collection,
runtimeEnv: VariableScope
) {
const collectionPath = path.resolve(
this.opt.outputFolder,
`${defaultCollectionFileName(collectionName, this.opt.runId!)}`
);
const envPath = path.resolve(
this.opt.outputFolder,
`${defaultEnvFileName(collectionName, this.opt.runId!)}`
);
const env = runtimeEnv.toJSON();
env.name = collectionName + ".env";
env._postman_variable_scope = "environment";
await this.fileLoader.writeFile(envPath, JSON.stringify(env, null, 2));
await this.fileLoader.writeFile(collectionPath, JSON.stringify(collection.toJSON(), null, 2));
const values: string[] = [];
for (const [k, v] of Object.entries(runtimeEnv.syncVariablesTo())) {
if (this.dataMasker.maybeSecretKey(k)) {
values.push(v as string);
}
}
this.dataMasker.addMaskedValues(values);
logger.info(`generate collection successfully!`);
logger.info(`Postman collection: ${collectionPath}`);
logger.info(`Postman env: ${envPath}`);
}
private async doRunCollection(runOptions: NewmanRunOptions) {
const newmanRun = async () =>
new Promise<NewmanRunSummary>((resolve, reject) => {
newman.run(runOptions, function (err, summary) {
if (summary.run.failures.length > 0) {
process.exitCode = 1;
}
if (err) {
logger.error(`collection run failed. ${err}`);
reject(err);
} else {
logger.info("collection run complete!");
resolve(summary);
}
});
});
return await newmanRun();
}
private async postRun(
scenario: Scenario,
reportExportPath: string,
runtimeEnv: VariableScope,
summary: NewmanRunSummary
) {
const keys = await this.swaggerAnalyzer.getAllSecretKey();
const values: string[] = [];
for (const [k, v] of Object.entries(runtimeEnv.syncVariablesTo())) {
if (this.dataMasker.maybeSecretKey(k)) {
values.push(v as string);
}
}
this.dataMasker.addMaskedValues(values);
this.dataMasker.addMaskedKeys(keys);
if (summary.environment.values) {
// add mask environment secret value
for (const item of summary.environment.values) {
if (this.dataMasker.maybeSecretKey(item.key)) {
this.dataMasker.addMaskedValues([item.value]);
}
}
}
const newmanReport = parseNewmanSummary(summary as any);
const newmanReportValidatorOption: NewmanReportValidatorOption = {
swaggerFilePaths: scenario._scenarioDef._swaggerFilePaths,
apiScenarioFilePath: scenario._scenarioDef._filePath,
reportOutputFilePath: defaultQualityReportFilePath(reportExportPath),
checkUnderFileRoot: false,
eraseXmsExamples: false,
eraseDescription: false,
markdown: this.opt.markdown,
junit: this.opt.junit,
html: this.opt.html,
armEndpoint: this.opt.env.armEndpoint,
runId: this.opt.runId,
skipValidation: this.opt.skipValidation,
generateExample: this.opt.generateExample,
savePayload: this.opt.savePayload,
};
const reportValidator = inversifyGetInstance(
NewmanReportValidator,
newmanReportValidatorOption
);
await reportValidator.initialize(scenario);
await reportValidator.generateReport(newmanReport);
}
}
@injectable()
export class PostmanCollectionGenerator {
public runnerMap = new Map<string, PostmanCollectionRunner>();
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
constructor(@inject(TYPES.opts) private opt: PostmanCollectionGeneratorOption) {
setDefaultOpts(opt, {
runId: generateRunId(),
});
}
public async run(scenarioFile: string, skipCleanUp: boolean = false): Promise<Collection> {
const runner = PostmanCollectionRunner.create({
scenarioFile: scenarioFile,
generator: this,
...this.opt,
});
const collection = await runner.run();
await runner.cleanUp(skipCleanUp);
await runner.generateReport();
return collection;
}
public async cleanUpAll(skipCleanUp: boolean = false): Promise<void> {
for (const runner of this.runnerMap.values()) {
await runner.cleanUp(skipCleanUp);
await runner.generateReport();
}
this.runnerMap.clear();
}
}