lib/exampleQualityValidator/exampleQualityValidator.ts (272 lines of code) (raw):

import { JSONPath } from "jsonpath-plus"; import { inject, injectable } from "inversify"; import * as _ from "lodash"; import { FileLoaderOption } from "../swagger/fileLoader"; import { JsonLoaderOption, JsonLoader } from "../swagger/jsonLoader"; import { SwaggerLoader, SwaggerLoaderOption } from "../swagger/swaggerLoader"; import { getTransformContext, TransformContext } from "../transform/context"; import { SchemaValidator } from "../swaggerValidator/schemaValidator"; 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 { nullableTransformer } from "../transform/nullableTransformer"; import { pureObjectTransformer } from "../transform/pureObjectTransformer"; import { AjvSchemaValidator } from "../swaggerValidator/ajvSchemaValidator"; import { getJsonPatchDiff } from "../apiScenario/diffUtils"; import { BodyTransformer } from "../apiScenario/bodyTransformer"; import { ErrorCodes } from "../util/constants"; import { SeverityString } from "../util/severity"; import { inversifyGetInstance, TYPES } from "./../inversifyUtils"; import { setDefaultOpts } from "./../swagger/loader"; import { traverseSwaggerAsync } from "./../transform/traverseSwagger"; import { applyGlobalTransformers, applySpecTransformers } from "./../transform/transformer"; import { LowerHttpMethods, Path, Operation, SwaggerSpec, SwaggerExample, BodyParameter, } from "./../swagger/swaggerTypes"; export interface ExampleQualityValidatorOption extends FileLoaderOption, JsonLoaderOption, SwaggerLoaderOption { swaggerFilePaths?: string[]; exampleMapping?: Map<string, string>; } interface exampleValidationContext { exampleName: string; exampleFilePath: string; bodyTransform?: BodyTransformer; } type ExampleValidationFunc = ( example: SwaggerExample, operation: Operation, jsonLoader: JsonLoader, exampleValidationContext: exampleValidationContext ) => Promise<any>; interface ExampleValidationRule { id: string; severity: SeverityString; code: string; jsonPath: string; message: string; exampleName: string; exampleFilePath: string; detail?: any; } // ARM RPC guide: https://github.com/Azure/azure-resource-manager-rpc/blob/master/v1.0/Addendum.md#provisioningstate-property // https://docs.microsoft.com/en-us/javascript/api/@azure/arm-databricks/provisioningstate?view=azure-node-latest const incorrectProvisioningState: ExampleValidationFunc = async ( example: SwaggerExample, _operation: Operation, _jsonLoader: JsonLoader, exampleValidationContext: exampleValidationContext ) => { const ret: ExampleValidationRule[] = []; if (example?.responses["200"] !== undefined) { const provisioningStatePath = "$.responses.200..[?(@.provisioningState)]"; const provisioningStates = JSONPath({ path: provisioningStatePath, json: example, resultType: "all", }); const terminalStatues = ["succeeded", "failed", "canceled", "ready", "created", "deleted"]; for (const it of provisioningStates) { if (!terminalStatues.includes(it.value.provisioningState.toLowerCase())) { ret.push({ id: ErrorCodes.IncorrectProvisioningState.id, severity: "Error", code: ErrorCodes.IncorrectProvisioningState.name, jsonPath: `${it.pointer}/provisioningState`, message: "The resource's provisioning state should be terminal status in http 200 response.", exampleName: exampleValidationContext.exampleName, exampleFilePath: exampleValidationContext.exampleFilePath, }); } } } return ret; }; const roundtripInconsistentProperty: ExampleValidationFunc = async ( example: SwaggerExample, operation: Operation, jsonLoader: JsonLoader, exampleValidationContext: exampleValidationContext ) => { const ret: ExampleValidationRule[] = []; if (operation._method === "put") { const reqSchema = operation.parameters?.filter((it) => it?.in === "body")[0] as BodyParameter; for (const statusCode of Object.keys(example.responses)) { if (operation.responses[statusCode] === undefined) { continue; } const respSchema = operation.responses[statusCode].schema; if (reqSchema && reqSchema.schema?.$ref === respSchema?.$ref) { const schema = jsonLoader.resolveRefObj(respSchema); const responseObj = await exampleValidationContext.bodyTransform?.resourceToRequest( example.responses[statusCode].body, schema! ); const requestObj = example.parameters.parameters; if (!!requestObj && !!responseObj) { const delta = getJsonPatchDiff(requestObj, responseObj, { includeOldValue: true, minimizeDiff: false, }); // filter replace operation. delta .filter((it) => (it as any).replace !== undefined) .map((it) => { (it as any).replace = `/${statusCode}/body${(it as any).replace}`; return it; }) .forEach((it) => ret.push({ id: ErrorCodes.RoundtripInconsistentProperty.id, severity: "Error", code: ErrorCodes.RoundtripInconsistentProperty.name, jsonPath: (it as any).replace, detail: JSON.stringify(it), message: `The property's value in the response is different from what was set in the request. Path: ${ (it as any).replace }. Request: ${(it as any).oldValue}. Response: ${(it as any).value}`, exampleName: exampleValidationContext.exampleName, exampleFilePath: exampleValidationContext.exampleFilePath, }) ); } } } } return ret; }; /* const recommendUsingBooleanType: ExampleValidationFunc = async ( example: SwaggerExample, _operation: Operation, _jsonLoader: JsonLoader, exampleValidationContext: exampleValidationContext ) => { const ret: ExampleValidationRule[] = []; const allElementPath = "$..*"; const allElements = JSONPath({ path: allElementPath, json: example, resultType: "all", }); const stringBooleans = ["false", "true"]; for (const it of allElements) { if (typeof it.value === "string" && stringBooleans.includes(it.value.toLowerCase())) { ret.push({ id: ErrorCodes.RecommendUsingBooleanType.id, severity: Severity.Warning, code: ErrorCodes.RecommendUsingBooleanType.name, path: `${it.pointer}`, message: "If the property only return two string value 'true' or 'false', recommend using boolean type.", exampleName: exampleValidationContext.exampleName, exampleFilePath: exampleValidationContext.exampleFilePath, }); } } //TODO: find schema by pointer. and check whether it type is string. return ret; }; */ @injectable() export class ExampleQualityValidator { private swaggerSpecs: SwaggerSpec[]; private initialized: boolean = false; private transformContext: TransformContext; private schemaValidator: SchemaValidator; private validationFuncs: ExampleValidationFunc[]; private operationMapping: { [operationId: string]: Operation }; // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility constructor( @inject(TYPES.opts) private opts: ExampleQualityValidatorOption, public jsonLoader: JsonLoader, private swaggerLoader: SwaggerLoader, private bodyTransformer: BodyTransformer ) { this.swaggerSpecs = []; this.validationFuncs = [incorrectProvisioningState, roundtripInconsistentProperty]; this.schemaValidator = new AjvSchemaValidator(this.jsonLoader); this.transformContext = getTransformContext(this.jsonLoader, this.schemaValidator, [ xmsPathsTransformer, resolveNestedDefinitionTransformer, referenceFieldsTransformer, discriminatorTransformer, allOfTransformer, noAdditionalPropertiesTransformer, nullableTransformer, pureObjectTransformer, ]); this.operationMapping = {}; } public static create(opts: ExampleQualityValidatorOption) { setDefaultOpts(opts, { eraseXmsExamples: false, eraseDescription: false, }); return inversifyGetInstance(ExampleQualityValidator, opts); } public async initialize() { if (this.initialized) { throw new Error("Already initialized"); } for (const swaggerFilePath of this.opts.swaggerFilePaths ?? []) { const swaggerSpec = await this.swaggerLoader.load(swaggerFilePath); this.swaggerSpecs.push(swaggerSpec); applySpecTransformers(swaggerSpec, this.transformContext); await traverseSwaggerAsync(swaggerSpec, { onOperation: async (operation) => { this.operationMapping[operation.operationId!] = operation; }, }); } applyGlobalTransformers(this.transformContext); } public async validateExternalExamples( examples: Array<{ exampleFilePath: string; example: SwaggerExample | undefined; operationId: string; }> ): Promise<ExampleValidationRule[]> { if (!this.initialized) { await this.initialize(); } let res: any[] = []; for (const example of examples) { const operationId = example.operationId; const operation = this.operationMapping[operationId]; if (operation === undefined) { throw new Error(`Cannot find operation for ${example.exampleFilePath}`); } const ctx: exampleValidationContext = { exampleName: example.exampleFilePath, exampleFilePath: example.exampleFilePath, bodyTransform: this.bodyTransformer, }; const exampleObj = example.example ?? ((await this.jsonLoader.load(example.exampleFilePath)) as SwaggerExample); for (const func of this.validationFuncs) { const ruleRes = await func(exampleObj, operation, this.jsonLoader, ctx); res = res.concat(ruleRes); } } return res; } /** * Validate swagger example quality. * @param filteredOperationIds If filtered is undefined, validate the whole x-ms-examples in swagger. * @returns */ public async validateSwaggerExamples( filteredOperationIds: string[] | undefined = undefined ): Promise<ExampleValidationRule[]> { if (!this.initialized) { await this.initialize(); } let res: ExampleValidationRule[] = []; const onOperation = async ( operation: Operation, _httpPath: Path, _method: LowerHttpMethods ) => { if ( filteredOperationIds === undefined || filteredOperationIds.includes(operation.operationId!) ) { const xMsExamples = operation["x-ms-examples"] ?? {}; for (const exampleName of Object.keys(xMsExamples)) { const example = xMsExamples[exampleName]; if (typeof example.$ref !== "string") { throw new Error(`Example doesn't use $ref: ${exampleName}`); } const exampleObj: SwaggerExample = (await this.jsonLoader.resolveFile( example.$ref )) as SwaggerExample; const ctx: exampleValidationContext = { exampleName: exampleName, exampleFilePath: this.jsonLoader.getRealPath(example.$ref), bodyTransform: this.bodyTransformer, }; for (const func of this.validationFuncs) { const ruleRes = await func(exampleObj, operation, this.jsonLoader, ctx); res = res.concat(ruleRes); } } } }; for (const spec of this.swaggerSpecs) { await traverseSwaggerAsync(spec, { onOperation: onOperation }); } return res; } }