lib/apiScenario/gen/testRecordingApiScenarioGenerator.ts (504 lines of code) (raw):
import * as path from "path";
import { inject, injectable } from "inversify";
import { dump as yamlDump } from "js-yaml";
import { HttpHeaders } from "@azure/core-http";
import { cloneDeep } from "lodash";
import { inversifyGetInstance, TYPES } from "../../inversifyUtils";
import { parseValidationRequest } from "../../liveValidation/liveValidator";
import { OperationSearcher } from "../../liveValidation/operationSearcher";
import { JsonLoader } from "../../swagger/jsonLoader";
import * as C from "../../util/constants";
import { SwaggerLoader } from "../../swagger/swaggerLoader";
import { getTransformContext } from "../../transform/context";
import { extractPathParamValue, pathRegexTransformer } from "../../transform/pathRegexTransformer";
import { referenceFieldsTransformer } from "../../transform/referenceFieldsTransformer";
import { applyGlobalTransformers, applySpecTransformers } from "../../transform/transformer";
import { xmsPathsTransformer } from "../../transform/xmsPathsTransformer";
import { ApiScenarioLoaderOption } from "../apiScenarioLoader";
import {
RawScenarioDefinition,
RawScenario,
Scenario,
StepRestCall,
Variable,
RawStepOperation,
RawVariableScope,
StepResponseAssertion,
} from "../apiScenarioTypes";
import { ApiScenarioClientRequest } from "../apiScenarioRunner";
import { Operation, Parameter, SwaggerExample } from "../../swagger/swaggerTypes";
import { unknownApiVersion, xmsLongRunningOperation } from "../../util/constants";
import { ArmApiInfo, ArmUrlParser } from "../armUrlParser";
import { SchemaValidator } from "../../swaggerValidator/schemaValidator";
import { getJsonPatchDiff } from "../diffUtils";
import { replaceAllInObject } from "../variableUtils";
import { logger } from ".././logger";
import { FileLoader } from "../../swagger/fileLoader";
const glob = require("glob");
export type SingleRequestTracking = ApiScenarioClientRequest & {
timeStart?: Date;
timeEnd?: Date;
url: string;
responseBody: any;
responseCode: number;
responseHeaders: { [headerName: string]: string };
};
export interface RequestTracking {
requests: SingleRequestTracking[];
description: string;
}
export interface TestRecordingApiScenarioGeneratorOption extends ApiScenarioLoaderOption {
specFolders: string[];
includeARM: boolean;
}
// const resourceGroupPathRegex = /^\/subscriptions\/[^\/]+\/resourceGroups\/[^\/]+$/i;
interface TestScenarioGenContext {
resourceTracking: Map<string, StepRestCall>;
resourceNames: Set<string>;
variables: Scenario["variables"];
lastUpdatedResource: string;
}
@injectable()
export class TestRecordingApiScenarioGenerator {
private testDefToWrite: Array<{ testDef: RawScenarioDefinition; filePath: string }> = [];
private operationSearcher: OperationSearcher;
private lroPollingUrls = new Set<string>();
private scope: RawScenarioDefinition["scope"] = "ResourceGroup";
public constructor(
@inject(TYPES.opts) private opts: TestRecordingApiScenarioGeneratorOption,
private swaggerLoader: SwaggerLoader,
private jsonLoader: JsonLoader,
private fileLoader: FileLoader,
private armUrlParser: ArmUrlParser,
@inject(TYPES.schemaValidator) private schemaValidator: SchemaValidator
) {
this.operationSearcher = new OperationSearcher((_) => {});
}
public static create(opts: TestRecordingApiScenarioGeneratorOption) {
return inversifyGetInstance(TestRecordingApiScenarioGenerator, opts);
}
public async initialize() {
const swaggerFilePaths = await this.getSwaggerFilePaths();
const transformCtx = getTransformContext(this.jsonLoader, this.schemaValidator, [
xmsPathsTransformer,
referenceFieldsTransformer,
pathRegexTransformer,
]);
for (const swaggerPath of swaggerFilePaths) {
const swaggerSpec = await this.swaggerLoader.load(swaggerPath);
applySpecTransformers(swaggerSpec, transformCtx);
this.operationSearcher.addSpecToCache(swaggerSpec);
}
applyGlobalTransformers(transformCtx);
}
private async getSwaggerFilePaths() {
if (this.opts.includeARM) {
const idx = this.opts.specFolders[0].indexOf("specification");
this.opts.specFolders.push(
path.join(this.opts.specFolders[0].substring(0, idx + "specification".length), "resources")
);
}
return await this.getMatchedPaths(
this.opts.specFolders.map((s) => path.join(path.resolve(s), "**/*.json"))
);
}
private async getMatchedPaths(jsonsPattern: string | string[]): Promise<string[]> {
let matchedPaths: string[] = [];
if (typeof jsonsPattern === "string") {
matchedPaths = glob.sync(jsonsPattern, {
ignore: C.DefaultConfig.ExcludedExamplesAndCommonFiles,
nodir: true,
});
} else {
for (const pattern of jsonsPattern) {
const res: string[] = glob.sync(pattern, {
ignore: C.DefaultConfig.ExcludedExamplesAndCommonFiles,
nodir: true,
});
for (const path of res) {
if (!matchedPaths.includes(path)) {
matchedPaths.push(path);
}
}
}
}
return matchedPaths;
}
public async writeGeneratedFiles(recordingPaths: string[]) {
const testDefToWrite = this.testDefToWrite;
this.testDefToWrite = [];
let path = "";
for (const p of recordingPaths) {
path += `# ${p}\n`;
}
for (const { testDef, filePath } of testDefToWrite) {
const fileContent =
"# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/documentation/api-scenario/references/v1.2/schema.json\n\n" +
"# Generated from test-proxy recording in:\n" +
`${path}` +
yamlDump(testDef);
return this.fileLoader.writeFile(filePath, fileContent);
}
}
public async generateTestDefinition(
requestTracking: RequestTracking[],
testScenarioFilePath: string
): Promise<RawScenarioDefinition> {
const testDef: RawScenarioDefinition = {
scope: "ResourceGroup",
scenarios: [],
};
for (const track of requestTracking) {
const testScenario = await this.generateTestScenario(track, testScenarioFilePath);
testDef.scenarios.push(testScenario);
}
testDef.scope = this.scope;
this.testDefToWrite.push({ testDef, filePath: testScenarioFilePath });
return testDef;
}
private async generateTestScenario(
requestTracking: RequestTracking,
_: string // testDefFilePath
): Promise<RawScenario> {
logger.info(`\nGenerating ${requestTracking.description}`);
const testScenario: RawScenario = {
scenario: requestTracking.description.replace(/[^a-zA-Z0-9_]/g, "_"),
variables: {},
steps: [],
};
const ctx: TestScenarioGenContext = {
resourceTracking: new Map(),
resourceNames: new Set(),
variables: {},
lastUpdatedResource: "",
};
const records = [...requestTracking.requests];
let lastOperation: Operation | undefined = undefined;
const steps = [];
while (records.length > 0) {
// const record = records[0];
const testStep = await this.generateTestStepRestCall(records, ctx);
if (!testStep) {
continue;
}
const { operation } = testStep;
if (lastOperation === operation && lastOperation?._method === "get") {
// Skip same get operation
continue;
}
lastOperation = operation;
steps.push(testStep);
}
this.convertVariables(ctx.variables, steps);
testScenario.steps = steps;
testScenario.steps.forEach((step) => {
Object.keys(step.variables ?? {}).forEach((key) => {
const variable = step.variables![key] as Variable;
step = step as RawStepOperation;
if (variable.value !== undefined) {
step.parameters = step.parameters || {};
step.parameters[key] = variable.value;
delete step.variables![key];
}
});
if (Object.keys(step.variables ?? {}).length === 0) {
step.variables = undefined;
}
});
testScenario.variables = Object.keys(ctx.variables).length > 0 ? ctx.variables : undefined;
return testScenario;
}
private searchOperation(record: SingleRequestTracking) {
const info = parseValidationRequest(record.url, record.method);
try {
const result = this.operationSearcher.search(info);
return result.operationMatch;
} catch (e) {
return undefined;
}
}
private async handleUnknownPath(
record: SingleRequestTracking,
records: SingleRequestTracking[]
): Promise<StepRestCall | undefined | null> {
if (this.lroPollingUrls.has(record.url) && record.method === "GET") {
return undefined;
}
switch (record.method) {
case "GET":
return null;
case "DELETE":
case "PUT":
const armInfo = this.armUrlParser.parseArmApiInfo(record.path, record.method);
await this.skipLroPoll(
records,
{
[xmsLongRunningOperation]: true,
} as Operation,
record,
armInfo
);
return null;
}
return null;
}
private async generateTestStepRestCall(
records: SingleRequestTracking[],
ctx: TestScenarioGenContext
): Promise<any | undefined | null> {
const record = records.shift()!;
const armInfo = this.armUrlParser.parseArmApiInfo(record.path, record.method);
if (ctx.lastUpdatedResource === armInfo.resourceUri && record.method === "GET") {
return undefined;
}
const responseAssertion = {} as StepResponseAssertion;
if (record.responseCode >= 400) {
responseAssertion[record.responseCode] = {};
}
const parseResult = this.parseRecord(record);
if (parseResult === undefined) {
console.warn(`Skip unknown request:\t${record.method}\t${record.url}`);
return await this.handleUnknownPath(record, records);
}
const { operation, requestParameters } = parseResult;
if (operation.operationId === "ResourceGroups_CreateOrUpdate") {
// todo check scope
return undefined;
}
const variables: Scenario["variables"] = {};
for (const paramKey of Object.keys(requestParameters)) {
const value = requestParameters[paramKey];
if (unwantedParams.has(paramKey) || ctx.variables[paramKey]?.value === value) {
continue;
}
let v: Variable;
if (typeof value === "string") {
v = { type: "string", value };
} else if (typeof value === "object") {
if (Array.isArray(value)) {
v = { type: "array", value: value };
} else {
v = { type: "object", value: value };
}
} else if (typeof value === "boolean") {
v = { type: "bool", value };
} else if (typeof value === "number") {
v = { type: "int", value };
} else {
console.warn(
`unknown type of value: ${typeof value}, key: ${paramKey}, method: ${record.method}`
);
continue;
}
variables[paramKey] = v;
}
const step = {
operationId: operation.operationId!,
variables: Object.keys(variables).length > 0 ? variables : undefined,
responses: Object.keys(responseAssertion).length > 0 ? responseAssertion : undefined,
operation,
};
await this.skipLroPoll(records, operation, record, armInfo);
if (["PUT", "PATCH", "DELETE"].includes(record.method)) {
// eslint-disable-next-line require-atomic-updates
ctx.lastUpdatedResource = armInfo.resourceUri;
}
return step;
}
private async skipLroPoll(
records: SingleRequestTracking[],
_operation: Operation,
initialRecord: SingleRequestTracking,
armInfo: ArmApiInfo
) {
let finalGet: SingleRequestTracking | undefined = undefined;
const headers = new HttpHeaders(initialRecord.responseHeaders);
for (const headerName of ["Operation-Location", "Azure-AsyncOperation", "Location"]) {
const headerValue = headers.get(headerName);
if (headerValue !== undefined && headerValue !== initialRecord.url) {
this.lroPollingUrls.add(headerValue);
}
}
while (records.length > 0) {
const record = records.shift()!;
if (record.method === "GET") {
if (record.path === armInfo.resourceUri) {
finalGet = record;
continue;
}
if (this.lroPollingUrls.has(record.url)) {
continue;
}
}
records.unshift(record);
break;
}
return finalGet;
}
private parseRecord(record: SingleRequestTracking) {
const operationMatch = this.searchOperation(record);
if (operationMatch === undefined) {
return undefined;
}
const { operation } = operationMatch;
const xHost = operation._path._spec["x-ms-parameterized-host"];
// if useSchemePrefix is false, the value should add scheme
if (xHost && xHost.useSchemePrefix === false && operationMatch.pathMatch[1]) {
operationMatch.pathMatch[1] = record.url.substring(
0,
record.url.indexOf(operationMatch.pathMatch[1]) + operationMatch.pathMatch[1].length
);
}
if (operation._path._spec._filePath.includes("data-plane")) {
this.scope = "None";
}
const pathParamValue = extractPathParamValue(operationMatch);
const requestParameters: SwaggerExample["parameters"] = {
"api-version": unknownApiVersion,
...pathParamValue,
};
for (const p of operation.parameters ?? []) {
const param = this.jsonLoader.resolveRefObj(p);
const paramValue = getParamValue(record, param);
if (paramValue !== undefined) {
requestParameters[param.name] = paramValue;
}
}
return { requestParameters, operation, pathParamValue };
}
private convertVariables(
root: Scenario["variables"],
scopes: Array<RawVariableScope & { operation?: Operation }>
) {
const keyToVariables = new Map<string, Variable[]>();
const unusedVariables = new Set<string>();
scopes.forEach((v) => {
Object.entries(v.variables ?? {}).forEach(([key, value]) => {
value = value as Variable;
key = `${key}_${typeMap[value.type]}`;
if (value.type === "string") {
key = `${key}_${value.value}`;
}
const vars = keyToVariables.get(key) ?? [];
vars.push(value);
keyToVariables.set(key, vars);
});
v.operation?.parameters?.forEach((p) => {
const param = this.jsonLoader.resolveRefObj(p);
if (v.variables?.[param.name] === undefined) {
unusedVariables.add(`${param.name}`);
}
});
delete v.operation;
});
keyToVariables.forEach((vars, key) => {
if (vars.length === 1 || unusedVariables.has(key.split("_")[0])) {
return;
}
const old = cloneDeep(vars[0]);
const [keyName] = key.split("_");
if (old.type === "string") {
if (unreplaceWords.includes(old.value!)) {
return;
}
if (root[keyName] !== undefined) {
for (let i = 1; ; i++) {
key = `${keyName}${i}`;
if (root[key] === undefined) {
break;
}
}
} else {
key = keyName;
}
root[key] = old;
replaceAllString(old.value!, key, scopes);
}
if (root[keyName] !== undefined) {
return;
}
root[keyName] = old;
if (old.type === "object" || old.type === "array") {
for (const newValue of vars) {
const diff = getJsonPatchDiff(old.value!, newValue.value!);
if (
diff.length > 0 &&
newValue.type == old.type &&
diff.filter((d) => Object.keys(d).includes("remove")).length <= 2
) {
newValue.patches = diff;
newValue.value = undefined;
}
if (diff.length === 0) {
newValue.value = undefined;
}
}
}
});
scopes.forEach((v) => {
Object.keys(v.variables ?? {}).forEach((key) => {
const variable = v.variables![key] as Variable;
if (variable.type === "array" || variable.type === "object") {
if (variable.patches === undefined && variable.value === undefined) {
delete v.variables![key];
}
} else if (root[key] && root[key].value === variable.value) {
delete v.variables![key];
}
});
});
}
}
const unwantedParams = new Set(["resourceGroupName", "api-version", "subscriptionId"]);
const getParamValue = (record: SingleRequestTracking, param: Parameter) => {
switch (param.in) {
case "body":
if (record.body?.location !== undefined) {
record.body.location = "$(location)";
}
return record.body;
case "header":
return record.headers[param.name];
case "query":
return record.query[param.name];
}
return undefined;
};
const unwantedKeys = new Set(["etag"]);
const eraseUnwantedKeys = (obj: any) => {
if (obj === null || obj === undefined) {
return;
}
if (typeof obj !== "object") {
return;
}
if (Array.isArray(obj)) {
for (let idx = 0; idx < obj.length; ++idx) {
eraseUnwantedKeys(obj[idx]);
}
return;
}
for (const key of Object.keys(obj)) {
if (unwantedKeys.has(key.toLowerCase())) {
obj[key] = undefined;
} else if (typeof obj[key] === "object") {
eraseUnwantedKeys(obj[key]);
}
}
};
const typeMap = {
object: "0",
array: "1",
bool: "2",
int: "3",
string: "4",
secureString: "5",
secureObject: "6",
};
const unreplaceWords = ["default"];
const replaceAllString = (toMatch: string, key: string, scopes: RawVariableScope[]) => {
toMatch = toMatch.toLowerCase();
scopes.forEach((v) => {
Object.entries(v.variables ?? {}).forEach(([k, value]) => {
replaceAllInObject(value, [toMatch], { [`${toMatch}`]: `$(${key})` });
value = value as Variable;
if (value.type === "string" && value.value === `$(${k})`) {
delete v.variables![k];
}
});
});
};