lib/generator/exampleGenerator.ts (474 lines of code) (raw):

import * as path from "path"; import deepdash from "deepdash"; import lodash from "lodash"; import { JsonLoader } from "../swagger/jsonLoader"; import { Operation, SwaggerSpec } from "../swagger/swaggerTypes"; import { traverseSwaggerAsync } from "../transform/traverseSwagger"; import { ModelValidationError } from "../util/modelValidationError"; import * as validate from "../validate"; import { AjvSchemaValidator } from "../swaggerValidator/ajvSchemaValidator"; import { TransformContext, getTransformContext } from "../transform/context"; import { xmsPathsTransformer } from "../transform/xmsPathsTransformer"; import { resolveNestedDefinitionTransformer } from "../transform/resolveNestedDefinitionTransformer"; import { referenceFieldsTransformer } from "../transform/referenceFieldsTransformer"; import { discriminatorTransformer } from "../transform/discriminatorTransformer"; import { allOfTransformer } from "../transform/allOfTransformer"; import { noAdditionalPropertiesTransformer } from "../transform/noAdditionalPropertiesTransformer"; import { applySpecTransformers, applyGlobalTransformers } from "../transform/transformer"; import { log } from "../util/logging"; import { inversifyGetInstance } from "../inversifyUtils"; import { ExampleRule, RuleSet } from "./exampleRule"; import * as util from "./util"; import Translator from "./translator"; import SwaggerMocker from "./swaggerMocker"; import { MockerCache, PayloadCache } from "./exampleCache"; const _ = deepdash(lodash); export default class Generator { private translator: Translator; private spec!: SwaggerSpec; private specFilePath: string; private payloadDir?: string; private jsonLoader: JsonLoader; private swaggerMocker: SwaggerMocker; private shouldMock: boolean; private mockerCache: MockerCache; private payloadCache: PayloadCache; private generationRule?: "Max" | "Min"; public readonly transformContext: TransformContext; public constructor(specFilePath: string, payloadDir?: string, generationRule?: "Max" | "Min") { this.generationRule = generationRule; this.shouldMock = payloadDir ? false : true; this.specFilePath = specFilePath; this.payloadDir = payloadDir; this.jsonLoader = inversifyGetInstance(JsonLoader, { useJsonParser: false, eraseXmsExamples: false, }); this.mockerCache = new MockerCache(); this.payloadCache = new PayloadCache(); this.swaggerMocker = new SwaggerMocker(this.jsonLoader, this.mockerCache, this.payloadCache); this.translator = new Translator( this.jsonLoader, this.payloadCache, this.shouldMock ? this.swaggerMocker : undefined ); const schemaValidator = new AjvSchemaValidator(this.jsonLoader); this.transformContext = getTransformContext(this.jsonLoader, schemaValidator, [ xmsPathsTransformer, resolveNestedDefinitionTransformer, referenceFieldsTransformer, discriminatorTransformer, allOfTransformer, noAdditionalPropertiesTransformer, ]); } private getSpecItem(spec: any, operationId: string): any { const paths = spec.paths; for (const pathName of Object.keys(paths)) { for (const methodName of Object.keys(paths[pathName])) { if (paths[pathName][methodName].operationId === operationId) { return { path: pathName, methodName, content: paths[pathName][methodName], }; } } } return null; } public async load() { this.spec = (await (this.jsonLoader.load(this.specFilePath) as unknown)) as SwaggerSpec; applySpecTransformers(this.spec, this.transformContext); applyGlobalTransformers(this.transformContext); await this.cacheExistingExamples(); } public async generateAll(): Promise<readonly ModelValidationError[]> { if (!this.spec) { await this.load(); } const errs: any[] = []; await traverseSwaggerAsync(this.spec, { onPath: async (apiPath, pathTemplate) => { apiPath._pathTemplate = pathTemplate; }, onOperation: async (operation: Operation, pathObject, methodName) => { const pathName = pathObject._pathTemplate; const specItem = { path: pathName, methodName, content: operation, }; const operationId: string = operation.operationId || ""; const errors = await this.generate(operationId, specItem); if (errors.length > 0) { errs.push(...errors); return false; } return true; }, }); return errs; } public async cacheExistingExamples() { if (!this.shouldMock) { return; } await traverseSwaggerAsync(this.spec, { onOperation: async (operation: Operation, pathObject, methodName) => { const pathName = pathObject._pathTemplate; const specItem = { path: pathName, methodName, content: operation, }; const examples = operation["x-ms-examples"] || undefined; if (!examples) { return; } const operationId = operation.operationId; /* const validateErrors = await validate.validateExamples(this.specFilePath, operationId, { }); if(validateErrors.length > 0) { console.warn(`invalid examples for operation:${operationId}.`); console.warn(validateErrors); return } */ for (const key of Object.keys(examples)) { if (key.match(new RegExp(`^${operationId}_.*_Gen$`))) { continue; } const example = this.jsonLoader.resolveRefObj(examples[key]); if (!example) { continue; } this.translator.extractParameters(specItem, example.parameters); for (const code of Object.keys(operation.responses)) { if (example.responses && example.responses[code]) { this.translator.extractResponse(specItem, example.responses[code], code); } } } return true; }, }); // reuse the payloadCache as exampleCache. this.payloadCache.mergeCache(); } private async generateExample(operationId: string, specItem: any, rule: ExampleRule) { this.translator.setRule(rule); this.swaggerMocker.setRule(rule); let example; console.log(`start generated example for ${operationId}, rule:${rule.ruleName}`); if (!this.shouldMock) { example = this.getExampleFromPayload(operationId, specItem, rule); if (!example) { return []; } } else { const xMsExamples = specItem?.content?.["x-ms-examples"] || {}; const xMsExampleKeys = Object.getOwnPropertyNames(xMsExamples); const title = xMsExampleKeys.length > 0 ? xMsExampleKeys[0] : ""; example = { title: title.length > 0 ? title.concat(" - generated by [", rule.ruleName!, "] rule") : specItem.content.summary ? specItem.content.summary : specItem.content.description ? specItem.content.description : `${operationId}_${rule.exampleNamePostfix}`, operationId: operationId, parameters: {}, responses: this.extractResponse(specItem, {}), }; this.swaggerMocker.mockForExample( example, specItem, this.spec, util.getBaseName(this.specFilePath).split(".")[0] ); } log.info(example); const unifiedExample = this.unifyCommonProperty(example); const newSpec = util.referenceExmInSpec( this.specFilePath, specItem.path, specItem.methodName, `${operationId}_${rule.exampleNamePostfix}_Gen` ); util.updateExmAndSpecFile( unifiedExample, newSpec, this.specFilePath, `${operationId}_${rule.exampleNamePostfix}_Gen.json` ); log.info(`start validating generated example for ${operationId}`); const validateErrors = await validate.validateExamples(this.specFilePath, operationId, { // consoleLogLevel: "error" }); if (validateErrors.length > 0) { log.error(`the validation raised below error:`); log.error(validateErrors); return validateErrors; } console.log(`generated example for ${operationId}, rule:${rule.ruleName} successfully!`); return []; } public async generate( operationId: string, specItem?: any ): Promise<readonly ModelValidationError[]> { if (!this.spec) { await this.load(); } if (!specItem) { specItem = this.getSpecItem(this.spec, operationId); if (!specItem) { console.error(`no specItem for the operation id ${operationId}`); return []; } } const ruleSet: RuleSet = []; if (this.generationRule) { ruleSet.push({ exampleNamePostfix: `${this.generationRule}imumSet`, ruleName: `${this.generationRule}imumSet`, }); } else { ruleSet.push( { exampleNamePostfix: "MaximumSet", ruleName: "MaximumSet", }, { exampleNamePostfix: "MinimumSet", ruleName: "MinimumSet", } ); } for (const rule of ruleSet) { const error = await this.generateExample(operationId, specItem, rule); if (error.length) { return error; } } return []; } private unifyCommonProperty(example: any) { if (!example || !example.parameters || !example.responses) { return; } type pathNode = string | number; type pathNodes = pathNode[]; const requestPaths = _.paths(example.parameters, { pathFormat: "array" }).map((v) => (v as pathNode[]).reverse() ); /** * construct a inverted index , the key is leaf property key, value is reverse of the path from the root to the leaf property. */ const invertedIndex = new Map<string | number, pathNodes[]>(); requestPaths.forEach((v) => { if (v.length && typeof v[0] === "string") { const parentPaths = invertedIndex.get(v[0]); if (!parentPaths) { invertedIndex.set(v[0], [v.slice(1)]); } else { parentPaths.push(v.slice(1)); } } }); /** * get two paths' common properties' count */ const getMatchedNodeCnt = (baseNode: pathNodes, destNode: pathNodes) => { let count = 0; baseNode.some((v, k) => { if (k < destNode.length && destNode[k] === v) { count++; return false; } else { return true; } }); return count; }; /** * update the property value of response using the same value which is found in the request */ const res = _.mapValuesDeep( example.responses, (value, key, parentValue, context) => { if (!parentValue) { log.warn(`parent is null`); } if ( ["integer", "number", "string"].some((type) => typeof value === type) && typeof key === "string" ) { const possiblePaths = invertedIndex.get(key); if (context.path && possiblePaths) { const basePath = (context.path as pathNodes).slice().reverse().slice(1); /** * to find out the most matchable path in the parameters */ const candidates = possiblePaths.filter( (apiPath) => getMatchedNodeCnt(basePath, apiPath) > 1 ); if (candidates.length === 0) { return value; } /** * if only one matched one path, just use it. */ if (candidates.length === 1) { const pathOfParameter = _.pathToString([key, ...candidates[0]].reverse()); const parameterValue = _.get(example.parameters, pathOfParameter); // console.debug(`use path ${pathOfParameter} ,value :${parameterValue} // -- original path:${_.pathToString(context.path as pathNodes)},value:${value}`); return parameterValue; } const mostMatched = candidates.reduce((previous, current) => { const countPrevious = getMatchedNodeCnt(basePath, previous); const countCurrent = getMatchedNodeCnt(basePath, current); return countPrevious < countCurrent ? current : previous; }); return _.get(example.parameters, _.pathToString([key, ...mostMatched].reverse())); } } return value; }, { leavesOnly: true, pathFormat: "array", } ); // console.debug(`unify common properties end!`); example.responses = res; return example; } private getExampleFromPayload(operationId: string, specItem: any, rule: ExampleRule) { if (this.payloadDir) { const subPaths = path.dirname(this.specFilePath).split(/\\|\//).slice(-3).join("/"); const payloadDir = path.join(this.payloadDir, subPaths); const payload: any = util.readPayloadFile(payloadDir, operationId); if (!payload) { log.info( `no payload file for operationId ${operationId} under directory ${path.resolve( payloadDir, operationId )} named with <statusCode>.json` ); return; } this.validatePayload(specItem, payload, operationId); this.cachePayload(specItem, payload); const example = { title: specItem.content.summary ? specItem.content.summary : specItem.content.description ? specItem.content.description : `${operationId}_${rule.exampleNamePostfix}`, operationId: operationId, parameters: this.extractRequest(specItem, payload), responses: this.extractResponse(specItem, payload), }; return example; } return undefined; } private cachePayload(specItem: any, payload: any) { /** * 1 cache parameter model * * 2 cache response model * * 3 merged cache */ this.extractRequest(specItem, payload); this.extractResponse(specItem, payload); this.payloadCache.mergeCache(); } private validatePayload(specItem: any, payload: any, operationId: string) { const specApiVersion = this.spec.info.version; for (const statusCode of Object.keys(payload)) { // remove payload with undefined status code if (!(statusCode in specItem.content.responses)) { delete payload[statusCode]; continue; } // remove payload with inconsistent api-version if (!("query" in payload[statusCode].liveRequest)) { continue; } const realApiVersion = payload[statusCode].liveRequest.query["api-version"]; if (realApiVersion && realApiVersion !== specApiVersion) { delete payload[statusCode]; log.error( `${operationId} payload ${statusCode}.json's api-version is ${realApiVersion}, inconsistent with swagger spec's api-version ${specApiVersion}` ); } } } private extractRequest(specItem: any, payload: any) { log.info("extractRequest"); const liveRequest: any = this.getRequestPayload(specItem, payload); if (!liveRequest) { log.warn(`no live request in payload`); return {}; } const request = this.translator.extractRequest(specItem, liveRequest) || {}; return request; } private getRequestPayload(specItem: any, payload: any) { const longRunning = util.isLongRunning(specItem); for (const statusCode in payload) { if (longRunning && statusCode === "200") { continue; } if ("liveRequest" in payload[statusCode]) { return payload[statusCode].liveRequest; } } } private extractResponse(specItem: any, payload: any) { log.info("extractResponse"); const specResp = specItem.content.responses; const longRunning: boolean = specItem.content["x-ms-long-running-operation"]; // below handled status code also should add in swaggerMocker.ts mockForExample() preHandledStatusCode array if (longRunning && !("202" in specResp) && !("201" in specResp)) { // console.warn('x-ms-long-running-operation is true, but no 202 or 201 response'); return {}; } if (longRunning && !("200" in specResp || "204" in specResp)) { // console.warn('x-ms-long-running-operation is true, but no 200 or 204 response'); } if (!longRunning && ("202" in specResp || "201" in specResp)) { // console.warn('x-ms-long-running-operation is not set true, but 202 or 201 response is provided'); return {}; } const resp: any = {}; if (!longRunning && "200" in specResp) { this.getResponseExample(specItem, payload, resp, "200", false); } if ("201" in specResp) { this.getResponseExample(specItem, payload, resp, "201", "200" in specResp); } if ("202" in specResp) { this.getResponseExample(specItem, payload, resp, "202", "200" in specResp); } if ("204" in specResp) { resp["204"] = {}; } return resp; } private getLongrunResp(specItem: any, payload: any) { const payload200: any = payload[200]; if (!payload200 || !("liveResponse" in payload200)) { // console.warn(`Payload doesn't have the response result for long running case`); return {}; } return { body: "schema" in specItem.content.responses["200"] ? this.translator.filterBodyContent( payload200.liveResponse.body, specItem.content.responses["200"].schema, false ) : undefined, }; } private getResponseExample( specItem: any, payloadGeneral: any, resp: any, statusCode: string, getAsyncResp: boolean ) { const payload: any = payloadGeneral[statusCode]; if (!payload || !("liveResponse" in payload)) { // console.warn(`no payload recording for status code = ${statusCode}`); resp[statusCode] = {}; } else { resp[statusCode] = this.translator.extractResponse( specItem, payload.liveResponse, statusCode ); } if (getAsyncResp) { resp["200"] = this.getLongrunResp(specItem, payloadGeneral); } } }