lib/apiScenario/gen/ApiTestRuleBasedGenerator.ts (494 lines of code) (raw):

import { existsSync, writeFileSync } from "fs"; import { dirname, join, relative, resolve } from "path"; import { mkdirpSync } from "fs-extra"; import { injectable } from "inversify"; import { dump } from "js-yaml"; import _ from "lodash"; import { inversifyGetInstance } from "../../inversifyUtils"; import { JsonLoader } from "../../swagger/jsonLoader"; import { setDefaultOpts } from "../../swagger/loader"; import { SwaggerLoader, SwaggerLoaderOption } from "../../swagger/swaggerLoader"; import { Path, SwaggerSpec } from "../../swagger/swaggerTypes"; import { traverseSwagger, traverseSwaggers } from "../../transform/traverseSwagger"; import { xmsLongRunningOperation } from "../../util/constants"; import { RawScenario, RawScenarioDefinition, RawStep, RawStepExample, RawStepOperation, } from "../apiScenarioTypes"; import { ApiScenarioYamlLoader } from "../apiScenarioYamlLoader"; import { RestlerApiScenarioGenerator } from "./restlerApiScenarioGenerator"; import { NoChildResourceCreated } from "./rules/noChildResourceCreated"; import { ResourceNameCaseInsensitive } from "./rules/resourceNameCaseInsensitive"; import { SystemDataExistsInResponse } from "./rules/systemDataExistsInResponse"; export type HttpVerb = "get" | "put" | "post" | "patch" | "delete" | "head"; export type ArmResourceKind = "Tracked" | "Proxy" | "Extension" | "None"; export type ResourceBasicOperationKind = "Get" | "CreateOrUpdate" | "Update" | "Delete"; export type ResourceOperationKind = ResourceBasicOperationKind | "Action" | "List"; export type PlatFormType = "RPaaS" | "ARM"; export type ApiTestGeneratorRule = { name: string; description: string; armRpcCodes?: string[]; resourceKinds?: ArmResourceKind[]; appliesTo: PlatFormType[]; useExample?: boolean; noCleanUp?: boolean; generator: ApiTestGenerator; }; type ApiTestGenerator = (resource: ArmResourceManipulator, base: RawScenario) => RawScenario | null; type ResourceOperation = { path: string; operationId: string; parameters?: []; responses?: { [index: string]: any }; examples: string[]; [xmsLongRunningOperation]?: boolean; kind: ResourceOperationKind; }; export interface ResourceManipulatorInterface { getResourceOperation(kind: ResourceBasicOperationKind): ResourceOperation; getListOperations(): ResourceOperation[]; getResourceActions(): ResourceOperation[]; getProperty(propName: string): any; getProperties(): any[]; getParentResource(): ResourceManipulatorInterface[]; getChildResource(): ResourceManipulatorInterface[]; } /* const ResourceBasicApiTestGenerator = { genResourceDependency: (resource: ArmResourceManipulator) => { }, genBasic:(resource:ArmResourceManipulator)=> {}, genResourceCreate: (resource: ArmResourceManipulator):RawStepOperation => { return { operationId: resource.getOperation("CreateOrUpdate")[0].operationId, } }, genResourceUpdate: (resource: ArmResourceManipulator) => {}, genResourceGet: (resource: ArmResourceManipulator) => {}, genResourceDelete: (resource: ArmResourceManipulator) => {}, };*/ // the class for manipulate the resource , includeing // get CRUD, list, actions operations export class ArmResourceManipulator implements ResourceManipulatorInterface { constructor( private swaggers: SwaggerSpec[], private jsonLoader: JsonLoader, private resAnalyzer: ArmResourceAnalyzer, private _resourceType: string, private path: string ) {} getListOperations(): ResourceOperation[] { return ( this.resAnalyzer .getResourceActions() .filter((res) => res.resourceType === this.resourceType && res.isListResource()) .map((res) => res.getOperation("List")) .reduce((pre, cur) => pre.concat(cur), []) || [] ); } getResourceActions(): ResourceOperation[] { return this.resAnalyzer .getResourceActions() .filter((res) => res.resourceType === this.resourceType && res.isListResource()) .map((res) => res.getOperation("Action")) .reduce((pre, cur) => pre.concat(cur), []); } getResourceOperation(kind: ResourceBasicOperationKind): ResourceOperation { return this.getOperation(kind)?.[0]; } get resourceType() { return this._resourceType; } public getOperation(kind: ResourceOperationKind): ResourceOperation[] { const ops: ResourceOperation[] = []; for (const swagger of this.swaggers) { traverseSwagger(swagger, { onPath: (path: Path, pathTemplate: string) => { if (pathTemplate === this.path) { function getHttpVerb(kind: ResourceOperationKind) { const map: { [index in ResourceOperationKind]: string } = { CreateOrUpdate: "put", Get: "get", Update: "patch", Delete: "delete", List: "get", Action: "post", }; return map[kind] as string; } function getRawOperation(kind: ResourceOperationKind) { return (path as any)[getHttpVerb(kind)]; } const rawOperation = getRawOperation(kind); if (rawOperation && rawOperation.operationId) { const operation = { operationId: rawOperation.operationId!, parameters: this.jsonLoader.resolveRefObj(rawOperation.parameters! as any), responses: this.jsonLoader.resolveRefObj(rawOperation.responses!), path: pathTemplate!, kind, examples: Object.values(rawOperation["x-ms-examples"] || {})?.map((e) => this.jsonLoader.getRealPath((e as any).$ref) ), }; ops.push(operation); } } }, }); } return ops; } private getPropertyInternal(schema: any, propName: string): any { const resolvedSchema = this.jsonLoader.resolveRefObj(schema); if (resolvedSchema.properties) { if (propName in resolvedSchema.properties) { return resolvedSchema.properties[propName]; } } if (resolvedSchema.allOf && Array.isArray(resolvedSchema.allOf)) { for (const base of resolvedSchema.allOf) { const result = this.getPropertyInternal(base, propName); if (result) { return result; } } } return undefined; } getProperty(propName: string): any { const response = this.getResourceOperation("CreateOrUpdate")?.responses?.["200"] || this.getResourceOperation("CreateOrUpdate")?.responses?.["201"] || this.getResourceOperation("Get")?.responses?.["200"]; if (response?.schema) { return this.getPropertyInternal(response?.schema, propName); } return undefined; } getProperties(): any[] { return []; } public isTrackedResource() { const putOp = this.getOperation("CreateOrUpdate")?.[0]; if (putOp) { return Object.entries(putOp.responses || []) .filter((entry) => entry[0] !== "default") .map((entry) => (entry[1] as any).schema) .some((schema) => schema && this.getPropertyInternal(schema, "location")); } return false; } public isExtensionResource() { const regEx = new RegExp(".*/providers/[^/]+/.*/providers/.*$", "gi"); return regEx.test(this.path); } public isListResource() { const regex = /.*_list.*/gi; const matches = this.getOperation("Get")?.[0]?.operationId?.match(regex); return !!matches; } public isResourceAction() { const result = this.getOperation("Action")?.[0]; return !!result; } private isSamePath(a: string, b: string) { const regex = /\{[\w\.]+\}/g; return a.replace(regex, "{}") === b.replace(regex, "{}"); } private getParentResourcePath(path: string) { return path.split("/").slice(0, -2).join("/"); } public getParentResource() { const parenetResPath = this.getParentResourcePath(this.path); return this.resAnalyzer .getResources() .filter((res) => this.isSamePath(res.path, parenetResPath)); } public getChildResource() { return this.resAnalyzer .getResources() .filter((res) => this.isSamePath(this.getParentResourcePath(res.path), this.path)); } } class ArmResourceDependencyGenerator { private _basicScenario: RawScenarioDefinition | undefined; private _restlerGenerator: RestlerApiScenarioGenerator | undefined; constructor( private _swaggers: string[], private _dependencyFile: string, private _outPutDir: string, private _basicScenarioFile?: string ) {} async generate(resoure: ArmResourceManipulator, useExample?: boolean) { const restlerGenerator = RestlerApiScenarioGenerator.create({ outputDir: this._outPutDir, dependencyPath: this._dependencyFile, swaggerFilePaths: this._swaggers, useExample: useExample, }); await restlerGenerator.initialize(); this._restlerGenerator = restlerGenerator; const baseScenario = await restlerGenerator.generateResourceDependency(resoure); if (this._basicScenarioFile) { const loader = inversifyGetInstance(ApiScenarioYamlLoader, {}); const [scenario] = await loader.load(this._basicScenarioFile); this._basicScenario = scenario; // extract varaibles if (baseScenario.variables) { Object.keys(baseScenario.variables).forEach((v: string) => { if (scenario.variables?.[v]) { baseScenario.variables![v] = scenario.variables[v]; } }); } function resolveExampleFile(scenarioFile: string, exampleFile: string) { return resolve(dirname(scenarioFile), exampleFile); } const steps = baseScenario.steps as RawStepOperation[]; const basicScenarioSteps = scenario.scenarios[0].steps as RawStepOperation[]; // extract steps baseScenario.steps = steps.map((s) => { const found = basicScenarioSteps.find((basic) => s.operationId === basic.operationId); const exampleFile = (found as RawStepExample)?.exampleFile; return found ? exampleFile ? { ...found, exampleFile: resolveExampleFile(this._basicScenarioFile!, exampleFile) } : found : s; }); } return baseScenario; } updateExampleFile(res: ArmResourceManipulator, scenario: RawScenario) { scenario.steps.forEach((s) => { if ("exampleFile" in s) { s.exampleFile = relative(resolve(this._outPutDir, res.resourceType, ".."), s.exampleFile); } }); } getPrepareAndCleanUp(res: ArmResourceManipulator) { const updateStepFile = (res: ArmResourceManipulator, steps: RawStep[], baseFile: string) => { return steps.map((s) => { if ("armTemplate" in s) { s.armTemplate = relative( resolve(this._outPutDir, res.resourceType, ".."), resolve(dirname(baseFile), s.armTemplate) ); } if ("armDeploymentScript" in s) { s.armDeploymentScript = relative( resolve(this._outPutDir, res.resourceType, ".."), resolve(dirname(baseFile), s.armDeploymentScript) ); } }); }; if (!!this._basicScenarioFile) { [ updateStepFile(res, this._basicScenario?.prepareSteps || [], this._basicScenarioFile), updateStepFile(res, this._basicScenario?.cleanUpSteps || [], this._basicScenarioFile), ]; } return [this._basicScenario?.prepareSteps, this._basicScenario?.cleanUpSteps]; } generateResourceCleanup(resource: ArmResourceManipulator, scenario: RawScenario) { this._restlerGenerator?.addCleanupSteps(resource, scenario); } } class ArmResourceAnalyzer { private _resources: ArmResourceManipulator[] | undefined; private _actions: ArmResourceManipulator[] | undefined; constructor(private _swaggers: SwaggerSpec[], private _jsonLoader: JsonLoader) { this.getResources(); } public getResourceType(path: string) { const index = path.lastIndexOf("/providers"); if (index !== -1) { return path .substring(index + 1) .split("/") .slice(2) .filter((v, i) => v && !(i % 2)) .join("/"); } return ""; } public getResources() { if (this._resources) { return this._resources; } this._resources = []; const specificResourcePathRegEx = new RegExp( "/providers/[^/]+(?:/\\w+/default|/\\w+/{[^/]+})+$", "gi" ); traverseSwaggers(this._swaggers, { onPath: (path: Path, pathTemplate: string) => { const resType = this.getResourceType(pathTemplate); if (specificResourcePathRegEx.test(pathTemplate) && path.put && resType) { const resource = new ArmResourceManipulator( this._swaggers, this._jsonLoader, this, resType, pathTemplate ); this._resources?.push(resource); } }, }); return this._resources; } public getResourceActions() { if (this._actions) { return this._actions; } const resourceActionRegEx = new RegExp( "/providers/[^/]+(?:/\\w+/\\w+|/\\w+/{[^/]+})*/\\w+$", "gi" ); this._actions = []; traverseSwaggers(this._swaggers, { onPath: (_path: Path, pathTemplate: string) => { const resType = this.getResourceType(pathTemplate); if (resourceActionRegEx.test(pathTemplate) && resType) { const resourceMani = new ArmResourceManipulator( this._swaggers, this._jsonLoader, this, resType, pathTemplate ); this._actions?.push(resourceMani); } }, }); return this._actions; } /** * /providers * /providers/NS/operations * /providers/Microsoft.Resources/checkResourceName * /subscription/{} * /subscription/{}/locations * /subscriptions * /{links} * /{applicationId} * subscription wide reads and actions * /subscriptions/{subscriptionId}/providers/Microsoft.Relay/checkNameAvailability */ public getTalentOrSubscriptionAction() { const resourceActionRegEx = new RegExp( "/providers/[^/]+/[operations|checkNameAvarilability]$", "gi" ); this._actions = []; traverseSwaggers(this._swaggers, { onPath: (_path: Path, pathTemplate: string) => { if (resourceActionRegEx.test(pathTemplate)) { const resourceMani = new ArmResourceManipulator( this._swaggers, this._jsonLoader, this, "None", pathTemplate ); this._actions?.push(resourceMani); } }, }); } public getTrackedResource(): ArmResourceManipulator[] { return this.getResources().filter((res) => res.isTrackedResource()); } public getProxyResource() { return this.getResources().filter((res) => !res.isTrackedResource()); } public getExtensionResource() { return this.getResources().filter((res) => res.isExtensionResource()); } } @injectable() export class ApiTestRuleBasedGenerator { constructor( private swaggerLoader: SwaggerLoader, private jsonLoader: JsonLoader, private rules: ApiTestGeneratorRule[], private swaggerFiles: string[], private dependencyFile?: string, private basicScenarioFile?: string ) {} async run(outputDir: string, platFormType: PlatFormType) { const swaggerSpecs = await Promise.all( this.swaggerFiles.map((f) => this.swaggerLoader.load(f)) ); const analyzer = new ArmResourceAnalyzer(swaggerSpecs, this.jsonLoader); const trackedResources = analyzer.getTrackedResource(); const proxyResources = analyzer.getProxyResource(); const extensionResources = analyzer.getExtensionResource(); const generateForResources = async ( resources: ArmResourceManipulator[], kind: ArmResourceKind ) => { let base: RawScenario = { steps: [] }; let dependency = this.dependencyFile ? new ArmResourceDependencyGenerator( this.swaggerFiles, this.dependencyFile, outputDir, this.basicScenarioFile ) : undefined; for (const resource of resources) { const definition: RawScenarioDefinition = { scope: "ResourceGroup", variables: undefined, prepareSteps: undefined, scenarios: [], cleanUpSteps: undefined, }; for (const rule of this.rules.filter( (rule) => rule.resourceKinds?.includes(kind) && rule.appliesTo.includes(platFormType) )) { // what if no dependency ?? base = (await dependency?.generate(resource, rule.useExample)) || base; if (base) { const apiSenarios = rule.generator(resource, base); if (apiSenarios) { apiSenarios.description = "[This scenario is auto-generated]" + rule.description; dependency?.updateExampleFile(resource, apiSenarios); dependency?.generateResourceCleanup(resource, apiSenarios); definition.scenarios.push({ scenario: rule.name, ...apiSenarios }); } } } if (definition.scenarios.length > 0) { const [prepare, cleanup] = dependency?.getPrepareAndCleanUp(resource) || [ undefined, undefined, ]; definition.prepareSteps = prepare; definition.cleanUpSteps = cleanup; this.writeFile(resource.resourceType, definition, outputDir); } } }; await generateForResources(trackedResources, "Tracked"); await generateForResources(proxyResources, "Proxy"); await generateForResources(extensionResources, "Extension"); } writeFile(resourType: string, definition: RawScenarioDefinition, outputDir: string) { const filePath = join(outputDir, `${resourType}.yaml`); if (!existsSync(dirname(filePath))) { mkdirpSync(dirname(filePath)); } 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); writeFileSync(filePath, fileContent); console.log(`${filePath} is generated.`); } } export const generateApiTestBasedOnRules = async ( swaggers: string[], dependencyFile: string, outputDir: string, basicScenarioFile?: string, isRPaaS?: string ) => { const opts: SwaggerLoaderOption = {}; setDefaultOpts(opts, { eraseXmsExamples: false, skipResolveRefKeys: ["x-ms-examples"], }); const swaggerLoader = inversifyGetInstance(SwaggerLoader, opts); const jsonLoader = inversifyGetInstance(JsonLoader, opts); const rules: ApiTestGeneratorRule[] = [ ResourceNameCaseInsensitive, SystemDataExistsInResponse, NoChildResourceCreated, ]; const generator = new ApiTestRuleBasedGenerator( swaggerLoader, jsonLoader, rules, swaggers, dependencyFile, basicScenarioFile ); await generator.run(outputDir, isRPaaS ? "RPaaS" : "ARM"); };