lib/apiScenario/gen/restlerApiScenarioGenerator.ts (532 lines of code) (raw):
import * as path from "path";
import Heap from "heap";
import { inject, injectable } from "inversify";
import { dump } from "js-yaml";
import { pathJoin, pathResolve, urlParse } from "@azure-tools/openapi-tools-common";
import { cloneDeep } from "lodash";
import { inversifyGetInstance, TYPES } from "../../inversifyUtils";
import { FileLoader } from "../../swagger/fileLoader";
import { JsonLoader } from "../../swagger/jsonLoader";
import { SwaggerLoader } from "../../swagger/swaggerLoader";
import {
SwaggerSpec,
LowerHttpMethods,
Schema,
Parameter,
Operation,
} from "../../swagger/swaggerTypes";
import { traverseSwagger } from "../../transform/traverseSwagger";
import { ApiScenarioLoaderOption } from "../apiScenarioLoader";
import {
RawScenario,
RawScenarioDefinition,
RawStepExample,
RawStepOperation,
Variable,
VarValue,
} from "../apiScenarioTypes";
import * as util from "../../generator/util";
import { setDefaultOpts } from "../../swagger/loader";
import Mocker from "../../generator/mocker";
import { logger } from ".././logger";
import { xmsExamples, xmsSkipUrlEncoding } from "../../util/constants";
import { ApiScenarioYamlLoader } from "../apiScenarioYamlLoader";
import { ArmResourceManipulator } from "./ApiTestRuleBasedGenerator";
export interface ApiScenarioGeneratorOption extends ApiScenarioLoaderOption {
swaggerFilePaths: string[];
dependencyPath: string;
outputDir: string;
useExample?: boolean;
scope?: string;
}
interface Dependency {
producer_endpoint: string;
producer_method: string;
producer_resource_name: string;
consumer_param: string;
}
interface Dependencies {
[path: string]: {
[method: string]: {
Path?: Dependency[];
Query?: Dependency[];
};
};
}
interface Node {
operationId: string;
method: LowerHttpMethods;
children: Map<string, Node>;
inDegree: number;
outDegree: number;
visited: boolean;
priority: number;
}
type ParameterNode = {
operationIds: Set<string>;
count: number;
name: string;
value: any;
};
const methodOrder: LowerHttpMethods[] = ["put", "get", "patch", "post", "delete"];
const envVariables = ["api-version", "subscriptionId", "resourceGroupName", "location"];
export const useRandom = {
flag: true,
};
@injectable()
export class RestlerApiScenarioGenerator {
private swaggers: SwaggerSpec[];
private graph: Map<string, Node>;
private parameters: Map<string, ParameterNode>;
private mocker: any;
private operations: Map<string, Operation>;
public constructor(
@inject(TYPES.opts) private opts: ApiScenarioGeneratorOption,
private swaggerLoader: SwaggerLoader,
private fileLoader: FileLoader,
private jsonLoader: JsonLoader,
private apiScenarioYamlLoader: ApiScenarioYamlLoader
) {
this.swaggers = [];
this.mocker = useRandom.flag
? new Mocker()
: {
mock: (paramSpec: any): any => {
switch (paramSpec.type) {
case "string":
return "test";
case "integer":
return 1;
case "number":
return 1;
case "boolean":
return true;
case "array":
return [];
}
},
};
}
public static create(opts: ApiScenarioGeneratorOption) {
setDefaultOpts(opts, {
swaggerFilePaths: [],
outputDir: ".",
dependencyPath: "",
eraseXmsExamples: false,
skipResolveRefKeys: [xmsExamples],
});
return inversifyGetInstance(RestlerApiScenarioGenerator, opts);
}
public async initialize() {
this.opts.outputDir = pathResolve(this.opts.outputDir);
this.opts.swaggerFilePaths = this.opts.swaggerFilePaths.map((p) => pathResolve(p));
this.operations = new Map();
for (const path of this.opts.swaggerFilePaths) {
const swagger = await this.swaggerLoader.load(path);
this.swaggers.push(swagger);
traverseSwagger(swagger, {
onOperation: (operation) => {
this.operations.set(operation.operationId!, operation);
},
});
}
await this.generateGraph();
}
public async generateResourceDependency(res: ArmResourceManipulator): Promise<RawScenario> {
const putOperation = res.getOperation("CreateOrUpdate")[0];
if (!putOperation) {
return { description: undefined, steps: [] };
}
const scenario = this.generateDependencySteps(res);
this.updateStepExample(scenario);
scenario.variables = this.getVariables(scenario);
return scenario;
}
public updateStepExample(scenario: RawScenario) {
if (this.opts.useExample) {
scenario.steps.forEach((step) => {
const operationId = (step as any).operationId;
const operation = this.operations.get(operationId);
const exampleObj = Object.values(operation?.["x-ms-examples"] || {})?.[0];
const exampleFile =
(step as RawStepExample).exampleFile || exampleObj?.$ref
? this.fileLoader.resolvePath(this.jsonLoader.getRealPath(exampleObj.$ref!))
: null;
if (exampleFile) {
(step as RawStepExample).exampleFile = exampleFile;
}
});
}
}
public async generate() {
const definition: RawScenarioDefinition = {
scope: this.opts.fileRoot?.includes("data-plane") ? "None" : "ResourceGroup",
authentication: undefined,
variables: undefined,
scenarios: [],
};
if (this.opts.scope) {
let scope;
if (urlParse(this.opts.scope)) {
definition.scope = this.opts.scope;
scope = this.opts.scope;
} else {
definition.scope = path.relative(this.opts.outputDir, this.opts.scope);
scope = path.resolve(process.cwd(), this.opts.scope);
}
const [scopeDef] = await this.apiScenarioYamlLoader.load(scope);
definition.authentication = scopeDef.scenarios[0].authentication;
}
definition.scenarios.push(this.generateSteps());
definition.variables = this.getVariables(definition.scenarios[0]);
if (this.opts.useExample) {
definition.scenarios[0].steps.forEach((step) => {
const operationId = (step as any).operationId;
const operation = this.operations.get(operationId);
const examples = operation?.[xmsExamples];
if (examples) {
const example = Object.values(examples)[0];
(step as RawStepExample).exampleFile = path.relative(
this.opts.outputDir,
this.fileLoader.resolvePath(this.jsonLoader.getRealPath(example.$ref!))
);
} else {
console.warn(`${operationId} has no example.`);
}
});
}
return definition;
}
public async writeFile(definition: RawScenarioDefinition) {
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" +
dump(definition);
const filePath = pathJoin(this.opts.outputDir, "basic.yaml");
await this.fileLoader.writeFile(filePath, fileContent);
logger.info(`${filePath} is generated.`);
}
private getVariables(scenario: RawScenario) {
const variables: { [name: string]: string | Variable } = {};
if (!this.parameters) {
const map = new Map();
for (const swagger of this.swaggers) {
traverseSwagger(swagger, {
onOperation: (operation) => {
for (let parameter of operation.parameters ?? []) {
parameter = this.jsonLoader.resolveRefObj(parameter);
if (
!parameter.required ||
envVariables.includes(parameter.name) ||
(parameter.in === "path" && parameter[xmsSkipUrlEncoding])
) {
continue;
}
let key: any = parameter.name;
if (parameter.in === "body") {
key = this.jsonLoader.resolveRefObj(parameter.schema!);
}
const value = map.get(key);
if (value) {
value.count++;
value.operationIds.add(operation.operationId);
} else {
map.set(key, {
operationIds: new Set<string>([operation.operationId!]),
name: parameter.name,
count: 1,
value: this.generateVariable(parameter),
});
}
}
},
});
}
this.parameters = map;
}
[...this.parameters.values()]
.sort((a, b) => b.count - a.count)
.forEach((v) => {
const operation = this.operations.get([...v.operationIds.values()][0]);
if (this.opts.useExample) {
let p = operation?.parameters?.find((p) => {
p = this.jsonLoader.resolveRefObj(p);
return p.name === v.name;
});
if (p) {
p = this.jsonLoader.resolveRefObj(p);
}
// for body,query parameter
if (
p?.in !== "path" &&
operation?.[xmsExamples] &&
Object.keys(operation?.[xmsExamples] ?? {}).length
) {
return;
}
}
const step = scenario.steps.find(
(s) =>
(s as RawStepOperation).operationId &&
v.operationIds.has((s as RawStepOperation).operationId)
);
if (!step) {
return;
}
if (!variables[v.name] && v.count > 1) {
variables[v.name] = v.value;
return;
}
if (!step?.variables) {
step.variables = {};
}
step.variables[v.name] = v.value;
});
return variables;
}
private generateVariable(parameter: Parameter): Variable | string | undefined {
const genValue = (name: string, schema: Schema): VarValue => {
if (util.isObject(schema)) {
const ret: VarValue = {};
const allOf = [...(schema.allOf ?? []), ...(schema.oneOf ?? [])];
const s = {
required: cloneDeep(schema.required) ?? [],
properties: cloneDeep(schema.properties) ?? {},
};
while (allOf.length > 0) {
const item = this.jsonLoader.resolveRefObj(allOf.shift()!);
allOf.push(...(item.allOf ?? []), ...(item.oneOf ?? []));
s.required = [...s.required, ...(item.required ?? [])];
s.properties = { ...s.properties, ...(item.properties ?? {}) };
}
for (const name of s.required) {
const prop = this.jsonLoader.resolveRefObj(s.properties[name]);
ret[name] = genValue(name, prop);
}
return ret;
}
if (schema.default) {
return schema.default;
}
if (schema.enum) {
return schema.enum[0];
}
if (schema.type === "string" && envVariables.includes(name)) {
return `$(${name})`;
}
if (schema.type === "array") {
const prop = this.jsonLoader.resolveRefObj(schema.items!) as Schema;
return this.mocker.mock(schema, name, genValue("", prop));
}
return this.mocker.mock(schema, name);
};
if (parameter.in === "body") {
const schema = this.jsonLoader.resolveRefObj(parameter.schema!);
const value: Variable = {
type: "object",
value: genValue(parameter.name, schema) as { [key: string]: VarValue },
};
return value;
}
if (parameter.in === "path" && parameter.type === "string") {
// set prefix length to 8, thus 8+6<15, which is the minimum max length of resource name
return { type: "string", prefix: `${parameter.name.toLocaleLowerCase().substring(0, 8)}` };
}
switch (parameter.type) {
case "string":
return this.mocker.mock(parameter, parameter.name);
case "integer":
case "number":
return { type: "int", value: this.mocker.mock(parameter, parameter.name) };
case "boolean":
return { type: "bool", value: this.mocker.mock(parameter, parameter.name) };
case "array":
return { type: "array", value: this.mocker.mock(parameter, parameter.name) };
default:
logger.warn(`Unsupported type ${parameter.type} of parameter ${parameter.name}`);
return undefined;
}
}
private generateDependencySteps(res: ArmResourceManipulator) {
const scenario: RawScenario = {
description: undefined,
variables: undefined,
steps: [],
};
function getPutOperationId() {
return res.getOperation("CreateOrUpdate")?.[0]?.operationId || "";
}
const sortedNodes: Node[] = [];
const cmp = (a: Node, b: Node) => {
const degree = b.inDegree - a.inDegree;
if (degree) {
return degree;
}
return 0;
};
const graph = this.graph;
function widthFirst(node: Node) {
const heap = new Heap<Node>(cmp);
for (const n of graph.values()) {
if (n.method === "put" && n.children.get(node.operationId)) {
heap.push(n);
}
sortedNodes.push(...heap.toArray());
}
heap.toArray().forEach((n) => widthFirst(n));
}
const node = this.getNode(getPutOperationId());
sortedNodes.push(node);
widthFirst(node);
const uniqNodes = sortedNodes.reverse().reduce(function (a: Node[], b: Node) {
if (a.indexOf(b) < 0) a.push(b);
return a;
}, []);
uniqNodes.forEach((node) => scenario.steps.push({ operationId: node.operationId }));
return scenario;
}
public addCleanupSteps(res: ArmResourceManipulator, scenario: RawScenario) {
const dependencyPutOperations = this.generateDependencySteps(res)?.steps.reverse();
const sortedNodes: Node[] = [];
for (const dependency of dependencyPutOperations) {
const node = this.getNode((dependency as RawStepOperation).operationId);
if (node) {
const deleteOperation = [...node.children.values()].find((n) => n.method === "delete");
if (deleteOperation) {
sortedNodes.push(deleteOperation);
}
}
}
sortedNodes.forEach((node) => scenario.steps.push({ operationId: node.operationId }));
return scenario;
}
private generateSteps() {
const scenario: RawScenario = {
scenario: "GeneratedScenario",
steps: [],
};
const heap = new Heap<Node>((a, b) => {
const priority = b.priority - a.priority;
if (priority) {
return priority;
}
const degree = b.outDegree - a.outDegree;
if (degree) {
return degree;
}
return methodOrder.indexOf(a.method) - methodOrder.indexOf(b.method);
});
for (const node of this.graph.values()) {
if (node.operationId.includes("CheckNameAvailability")) {
scenario.steps.push({ operationId: node.operationId });
node.visited = true;
continue;
}
if (node.inDegree === 0 && node.method === "put") {
heap.push(node);
node.visited = true;
}
}
const deleteStack: Node[] = [];
while (!heap.empty()) {
const node = heap.pop()!;
scenario.steps.push({ operationId: node.operationId });
const operation = node.operationId.split("_")[0];
for (const n of node.children.values()) {
n.inDegree--;
if (n.inDegree === 0 && n.method === "put") {
heap.push(n);
n.visited = true;
}
}
if (node.method !== "put") {
continue;
}
for (const n of this.graph.values()) {
if (n.inDegree === 0 && !n.visited && n.operationId.split("_")[0] === operation) {
n.priority = 1;
if (n.method === "delete") {
n.visited = true;
deleteStack.push(n);
} else {
heap.push(n);
n.visited = true;
}
}
}
}
for (const node of this.graph.values()) {
if (!node.visited) {
scenario.steps.push({ operationId: node.operationId });
if (node.inDegree !== 0) {
console.error("node inDegree is not 0 ", node.operationId, node.method);
}
}
}
while (deleteStack.length > 0) {
const node = deleteStack.pop()!;
scenario.steps.push({ operationId: node.operationId });
}
return scenario;
}
private async generateGraph() {
this.graph = new Map<string, Node>();
const dependencies = (await this.jsonLoader.load(this.opts.dependencyPath)) as Dependencies;
for (const path of Object.keys(dependencies)) {
if (!path.startsWith("/")) {
continue;
}
for (const method of Object.keys(dependencies[path])) {
const operationId =
this.getOperationId(path, method) || this.getOperationId(path + "/", method);
if (!operationId) {
console.warn(`can't find operationId, ${path} ${method}`);
continue;
}
const node = this.getNode(operationId);
node.method = method.toLowerCase() as LowerHttpMethods;
for (const dependency of [
...(dependencies[path][method].Path ?? []),
...(dependencies[path][method].Query ?? []),
]) {
if (dependency.producer_endpoint && dependency.producer_method) {
const producerOperationId = this.getOperationId(
dependency.producer_endpoint,
dependency.producer_method
);
this.addDependency(operationId, producerOperationId);
}
}
}
}
}
private addDependency(operationId: string, producerOperationId: string) {
const node = this.getNode(operationId);
const dependNode = this.getNode(producerOperationId);
node.inDegree++;
dependNode.children.set(operationId, node);
dependNode.outDegree++;
}
private getNode(operationId: string) {
if (!this.graph.has(operationId)) {
const node: Node = {
operationId: operationId,
children: new Map<string, Node>(),
inDegree: 0,
outDegree: 0,
visited: false,
method: "get",
priority: 0,
};
this.graph.set(operationId, node);
}
return this.graph.get(operationId)!;
}
private getOperationId(path: string, method: string) {
const m = method.toLowerCase() as LowerHttpMethods;
for (const spec of this.swaggers) {
const operationId = spec.paths[path]?.[m]?.operationId;
if (operationId) {
return operationId;
}
}
return "";
}
}