lib/swaggerValidator/trafficValidator.ts (353 lines of code) (raw):

import * as fs from "fs"; import * as path from "path"; import { resolve as pathResolve } from "path"; import { glob } from "glob"; import { FilePosition } from "@azure-tools/openapi-tools-common"; import { LiveValidationIssue, LiveValidator, RequestResponsePair, } from "../liveValidation/liveValidator"; import { DefaultConfig } from "../util/constants"; import { apiValidationErrors, ErrorCodeConstants } from "../util/errorDefinitions"; import { OperationContext } from "../liveValidation/operationValidator"; import { Options } from "../validate"; import { inversifyGetContainer, inversifyGetInstance } from "../inversifyUtils"; import { findPathsToKey, findPathToValue, getApiVersionFromFilePath } from "../util/utils"; import { SwaggerLoader, SwaggerLoaderOption } from "../swagger/swaggerLoader"; import { getFilePositionFromJsonPath } from "../util/jsonUtils"; import { LiveValidatorLoader } from "../liveValidation/liveValidatorLoader"; import { traverseSwagger } from "../transform/traverseSwagger"; export interface TrafficValidationOptions extends Options { sdkPackage?: string; sdkLanguage?: string; reportPath?: string; overrideLinkInReport?: boolean; outputExceptionInReport?: boolean; specLinkPrefix?: string; payloadLinkPrefix?: string; markdownPath?: string; jsonReportPath?: string; } export interface TrafficValidationIssue { payloadFilePath?: string; payloadFilePathPosition?: FilePosition | undefined; specFilePath?: string; errors?: LiveValidationIssueWithSource[]; runtimeExceptions?: RuntimeException[]; operationInfo?: OperationContext; } type LiveValidationIssueWithSource = LiveValidationIssue & { issueSource?: "request" | "response"; }; export interface RuntimeException { code: string; message: string; spec?: string[]; } export interface OperationCoverageInfo { readonly spec: string; readonly apiVersion: string; readonly coveredOperations: number; readonly coveredOperationsList: OperationMeta[]; readonly validationFailOperations: number; readonly unCoveredOperations: number; readonly unCoveredOperationsList: OperationMeta[]; readonly unCoveredOperationsListGen: unCoveredOperationsFormat[]; readonly totalOperations: number; readonly coverageRate: number; } export interface OperationMeta { readonly operationId: string; } export interface unCoveredOperationsFormatInner extends OperationMeta { readonly key: string; } export interface unCoveredOperationsFormat { readonly operationIdList: unCoveredOperationsFormatInner[]; } export class TrafficValidator { private liveValidator: LiveValidator; private trafficValidationResult: TrafficValidationIssue[] = []; private trafficFiles: string[] = []; private specPath: string; private trafficPath: string; private loader?: LiveValidatorLoader; private swaggerLoader?: SwaggerLoader; private trafficOperation: Map<string, string[]> = new Map<string, string[]>(); private validationFailOperations: Map<string, string[]> = new Map<string, string[]>(); private coverageData: Map<string, number> = new Map<string, number>(); public operationSpecMapper: Map<string, string[]> = new Map<string, string[]>(); public operationCoverageResult: OperationCoverageInfo[] = []; public operationUndefinedResult: number = 0; public constructor(specPath: string, trafficPath: string) { this.specPath = pathResolve(specPath); this.trafficPath = pathResolve(trafficPath); } public async initialize() { const specPathStats = fs.statSync(this.specPath); const trafficPathStats = fs.statSync(this.trafficPath); let specFileDirectory = ""; let swaggerPathsPattern = "**/*.json"; if (specPathStats.isFile()) { specFileDirectory = path.dirname(this.specPath); swaggerPathsPattern = path.basename(this.specPath); } else if (specPathStats.isDirectory()) { specFileDirectory = this.specPath; } if (trafficPathStats.isFile()) { this.trafficFiles.push(this.trafficPath); } else if (trafficPathStats.isDirectory()) { const searchPattern = path.join(this.trafficPath, "**/*.json"); const matchedPaths = glob.sync(searchPattern, { nodir: true, }); for (const filePath of matchedPaths) { this.trafficFiles.push(filePath); } } const liveValidationOptions = { checkUnderFileRoot: false, loadValidatorInBackground: false, directory: specFileDirectory, swaggerPathsPattern: [swaggerPathsPattern], excludedSwaggerPathsPattern: DefaultConfig.ExcludedExamplesAndCommonFiles, git: { shouldClone: false, }, }; this.liveValidator = new LiveValidator(liveValidationOptions); await this.liveValidator.initialize(); const container = inversifyGetContainer(); this.loader = inversifyGetInstance(LiveValidatorLoader, { container, fileRoot: liveValidationOptions.directory, ...liveValidationOptions, loadSuppression: Object.keys(apiValidationErrors), }); const swaggerPaths = this.liveValidator.swaggerList; while (swaggerPaths.length > 0) { const swaggerPath = swaggerPaths.shift()!; let spec; try { spec = await this.loader.load(pathResolve(swaggerPath)); } catch (e) { console.log( `Exception when loading spec, ErrorMessage: ${(e as any)?.message}; ErrorStack: ${ (e as any)?.stack }.` ); } if (spec !== undefined) { // Get Swagger - operation mapper. if (this.operationSpecMapper.get(swaggerPath) === undefined) { this.operationSpecMapper.set(swaggerPath, []); } traverseSwagger(spec, { onOperation: (operation) => { if ( operation.operationId !== undefined && !this.operationSpecMapper.get(swaggerPath)?.includes(operation.operationId) ) { this.operationSpecMapper.get(swaggerPath)!.push(operation.operationId); } }, }); } } } public async validate(): Promise<TrafficValidationIssue[]> { let payloadFilePath; const swaggerOpts: SwaggerLoaderOption = { setFilePath: false, }; this.swaggerLoader = inversifyGetInstance(SwaggerLoader, swaggerOpts); try { for (const trafficFile of this.trafficFiles) { payloadFilePath = trafficFile; const payload: RequestResponsePair = require(trafficFile); const validationResult = await this.liveValidator.validateLiveRequestResponse(payload); let operationInfo = validationResult.requestValidationResult?.operationInfo; const liveRequest = payload.liveRequest; const opInfo = await this.liveValidator.getOperationInfo(liveRequest); const errorResult: LiveValidationIssueWithSource[] = []; const runtimeExceptions: RuntimeException[] = []; if (validationResult.requestValidationResult.isSuccessful === undefined) { runtimeExceptions.push(validationResult.requestValidationResult.runtimeException!); } else if (validationResult.requestValidationResult.isSuccessful === false) { errorResult.push( ...validationResult.requestValidationResult.errors.map((e) => { (e as LiveValidationIssueWithSource).issueSource = "request"; return e as LiveValidationIssueWithSource; }) ); } if (validationResult.responseValidationResult.isSuccessful === undefined) { runtimeExceptions.push(validationResult.responseValidationResult.runtimeException!); } else if (validationResult.responseValidationResult.isSuccessful === false) { errorResult.push( ...validationResult.responseValidationResult.errors.map((e) => { (e as LiveValidationIssueWithSource).issueSource = "response"; return e as LiveValidationIssueWithSource; }) ); } const trafficSpec = await this.swaggerLoader.load(payloadFilePath); let liveRequestResponseList; if (validationResult.requestValidationResult.isSuccessful) { liveRequestResponseList = findPathsToKey({ key: "liveResponse", obj: trafficSpec }); } else { liveRequestResponseList = findPathsToKey({ key: "liveRequest", obj: trafficSpec }); } const liveRequestResponsePosition = getFilePositionFromJsonPath( trafficSpec, liveRequestResponseList[0] ); let swaggerFiles: string[] = []; if (liveRequest.url.includes("provider")) { // This is for validation of resource-manager swaggerFiles = this.findSwaggerByOperationInfo(opInfo.info); } else { // This is for validation of data-plane swaggerFiles = this.findSwaggerByOperationId(opInfo.info); } if (swaggerFiles.length !== 0) { for (const swaggerFile of swaggerFiles) { if (this.trafficOperation.get(swaggerFile) === undefined) { this.trafficOperation.set(swaggerFile, []); } if (!this.trafficOperation.get(swaggerFile)?.includes(opInfo.info.operationId)) { this.trafficOperation.get(swaggerFile)?.push(opInfo.info.operationId); } if ( validationResult.requestValidationResult.isSuccessful === false || validationResult.requestValidationResult.isSuccessful === undefined || validationResult.responseValidationResult.isSuccessful === false || validationResult.responseValidationResult.isSuccessful === undefined || validationResult.runtimeException !== undefined ) { if (this.validationFailOperations.get(swaggerFile) === undefined) { this.validationFailOperations.set(swaggerFile, []); } if ( !this.validationFailOperations.get(swaggerFile)?.includes(opInfo.info.operationId) ) { this.validationFailOperations.get(swaggerFile)?.push(opInfo.info.operationId); } const spec = swaggerFile && (await this.swaggerLoader.load(swaggerFile)); const operationIdList = findPathsToKey({ key: "operationId", obj: spec }); const operationId = findPathToValue(operationIdList, spec, operationInfo.operationId); const operationIdPosition = getFilePositionFromJsonPath(spec, operationId[0]); operationInfo = Object.assign(operationInfo, { position: operationIdPosition }); this.trafficValidationResult.push({ specFilePath: swaggerFile, payloadFilePath, payloadFilePathPosition: liveRequestResponsePosition, errors: errorResult, runtimeExceptions, operationInfo, }); } } } else { console.log(`Error: Undefined operation ${JSON.stringify(opInfo.info)}`); this.operationUndefinedResult = this.operationUndefinedResult + 1; } } } catch (err) { const msg = `Detail error message:${(err as any)?.message}. ErrorStack:${ (err as any)?.Stack }`; this.trafficValidationResult.push({ payloadFilePath, runtimeExceptions: [ { code: ErrorCodeConstants.RUNTIME_ERROR, message: msg, }, ], }); } let coveredOperations: number; let coverageRate: number; let validationFailOperations: number; let coveredOperationsList: OperationMeta[]; let unCoveredOperationsList: unCoveredOperationsFormatInner[]; this.operationSpecMapper.forEach((value: string[], key: string) => { // identify the spec has been match traffic file let isMatch: boolean = true; const unCoveredOperationsListFormat: unCoveredOperationsFormat[] = []; coveredOperationsList = []; unCoveredOperationsList = []; if (this.trafficOperation.get(key) === undefined) { coveredOperations = 0; coverageRate = 0; this.coverageData.set(key, 0); isMatch = false; } else if (value !== undefined && value.length !== 0) { const validatedOperations = this.trafficOperation.get(key); coveredOperations = validatedOperations!.length; coverageRate = coveredOperations / value.length; this.coverageData.set(key, coverageRate); const unValidatedOperations = [...value]; validatedOperations!.forEach((element) => { coveredOperationsList.push({ operationId: element }); unValidatedOperations.splice(unValidatedOperations.indexOf(element), 1); }); unValidatedOperations.forEach((element) => { unCoveredOperationsList.push({ key: element.split("_")[0], operationId: element, }); }); const unCoveredOperationsInnerList: unCoveredOperationsFormatInner[][] = Object.values( unCoveredOperationsList.reduce( (res: { [key: string]: unCoveredOperationsFormatInner[] }, item) => { /* eslint-disable no-unused-expressions */ res[item.key] ? res[item.key].push(item) : (res[item.key] = [item]); /* eslint-enable no-unused-expressions */ return res; }, {} ) ); unCoveredOperationsInnerList.forEach((element) => { unCoveredOperationsListFormat.push({ operationIdList: element, }); }); } else { isMatch = false; coveredOperations = 0; coverageRate = 0; this.coverageData.set(key, 0); } if (this.validationFailOperations.get(key) === undefined) { validationFailOperations = 0; } else { validationFailOperations = this.validationFailOperations.get(key)!.length; } const sortedUnCoveredOperationsList = unCoveredOperationsList.sort(function (op1, op2) { const opId1 = op1.operationId; const opId2 = op2.operationId; if (opId1 < opId2) { return -1; } if (opId1 > opId2) { return 1; } return 0; }); const sortedCoveredOperationsList = coveredOperationsList.sort(function (op1, op2) { const opId1 = op1.operationId; const opId2 = op2.operationId; if (opId1 < opId2) { return -1; } if (opId1 > opId2) { return 1; } return 0; }); /** * Sort untested operationId by bubble sort * Controlling the results of localeCompare can set the sorting method * X.localeCompare(Y) > 0 descending sort * X.localeCompare(Y) < 0 ascending sort */ for (let i = 0; i < unCoveredOperationsListFormat.length - 1; i++) { for (let j = 0; j < unCoveredOperationsListFormat.length - 1 - i; j++) { if ( unCoveredOperationsListFormat[j].operationIdList[0].key.localeCompare( unCoveredOperationsListFormat[j + 1].operationIdList[0].key ) > 0 ) { var temp = unCoveredOperationsListFormat[j]; unCoveredOperationsListFormat[j] = unCoveredOperationsListFormat[j + 1]; unCoveredOperationsListFormat[j + 1] = temp; } } } isMatch && this.operationCoverageResult.push({ spec: key, apiVersion: getApiVersionFromFilePath(key), coveredOperations, coverageRate, unCoveredOperations: value.length - coveredOperations, totalOperations: value.length, validationFailOperations: validationFailOperations, coveredOperationsList: sortedCoveredOperationsList, unCoveredOperationsList: sortedUnCoveredOperationsList, unCoveredOperationsListGen: unCoveredOperationsListFormat, }); }); return this.trafficValidationResult; } private findSwaggerByOperationInfo(operationInfo: OperationContext): string[] { let result: string[] = []; if (operationInfo.validationRequest === undefined) { return result; } for (const key of this.operationSpecMapper.keys()) { const value = this.operationSpecMapper.get(key); if ( key.toLowerCase().includes(operationInfo.validationRequest?.providerNamespace) && (key.includes(operationInfo.apiVersion) || key.toLowerCase().includes(operationInfo.apiVersion)) ) { if (value!.includes(operationInfo.operationId)) { result.push(key); } } } return result; } private findSwaggerByOperationId(operationInfo: OperationContext): string[] { let result: string[] = []; for (const key of this.operationSpecMapper.keys()) { const value = this.operationSpecMapper.get(key); if ( value!.includes(operationInfo.operationId) && (key.includes(operationInfo.apiVersion) || key.toLowerCase().includes(operationInfo.apiVersion)) ) { result.push(key); } } return result; } }