lib/swaggerValidator/semanticValidator.ts (530 lines of code) (raw):
import {
FilePosition,
getInfo,
getRootObjectInfo,
ParseError,
} from "@azure-tools/openapi-tools-common";
import { inject, injectable } from "inversify";
import swaggerSchemaDoc from "@autorest/schemas/swagger-extensions.json";
import swaggerExampleSchemaDoc from "@autorest/schemas/example-schema.json";
import jsonPointer from "json-pointer";
import { inversifyGetContainer, inversifyGetInstance, TYPES } from "../inversifyUtils";
import { $id, JsonLoader, JsonLoaderRefError } from "../swagger/jsonLoader";
import { SwaggerLoaderOption } from "../swagger/swaggerLoader";
import { Parameter, refSelfSymbol, Schema, SwaggerSpec } from "../swagger/swaggerTypes";
import { BaseValidationError } from "../util/baseValidationError";
import {
getOavErrorMeta,
SemanticValidationErrorCode,
semanticValidationErrors,
} from "../util/errorDefinitions";
import { ValidationResultSource } from "../util/validationResultSource";
import { LiveValidatorLoader } from "../liveValidation/liveValidatorLoader";
import { getTransformContext, TransformContext } from "../transform/context";
import { referenceFieldsTransformer } from "../transform/referenceFieldsTransformer";
import { resolveNestedDefinitionTransformer } from "../transform/resolveNestedDefinitionTransformer";
import { xmsPathsTransformer } from "../transform/xmsPathsTransformer";
import { applyGlobalTransformers, applySpecTransformers } from "../transform/transformer";
import { traverseSwagger, traverseSwaggerAsync } from "../transform/traverseSwagger";
import { FileLoader } from "../swagger/fileLoader";
import { getFilePositionFromJsonPath, jsonPathToPointer } from "../util/jsonUtils";
import { pathRegexTransformer } from "../transform/pathRegexTransformer";
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 { isSuppressedInPath, SuppressionLoader } from "../swagger/suppressionLoader";
import { xmsDiscriminatorValue } from "../util/constants";
import {
SchemaValidateFunction,
SchemaValidateIssue,
SchemaValidator,
SchemaValidatorOption,
} from "./schemaValidator";
import swagger2SchemaDoc from "./swagger-2.0.json";
export interface SemanticErrorDetail {
inner?: any; // Compatible with old NodeError. Always undefined.
message: string;
code: SemanticValidationErrorCode;
position?: FilePosition;
url?: string;
jsonPath?: string;
}
export interface SemanticValidationError extends BaseValidationError<SemanticErrorDetail> {
source?: ValidationResultSource;
path?: string;
readonly inner?: any;
readonly "json-path"?: string;
}
export interface SemanticValidationOption extends SwaggerLoaderOption, SchemaValidatorOption {}
const loadSuppression = [];
for (const errorCode of Object.keys(semanticValidationErrors)) {
const meta = semanticValidationErrors[errorCode as SemanticValidationErrorCode];
if ("id" in meta) {
loadSuppression.push(meta.id);
}
}
// Set isArmCall flag to true so that the ARM rules schema will be applied to swaggers too
const defaultOpts: SemanticValidationOption = {
eraseDescription: false,
eraseXmsExamples: false,
useJsonParser: true,
loadSuppression,
isArmCall: true,
};
@injectable()
export class SwaggerSemanticValidator {
private validateSwaggerSch!: SchemaValidateFunction;
public constructor(
@inject(TYPES.opts) _opts: SemanticValidationOption,
private jsonLoader: JsonLoader,
private fileLoader: FileLoader,
private suppressionLoader: SuppressionLoader,
private liveValidatorLoader: LiveValidatorLoader,
@inject(TYPES.schemaValidator) private schemaValidator: SchemaValidator
) {}
public async initialize() {
this.fileLoader.preloadExtraFile(
"https://raw.githubusercontent.com/Azure/autorest/master/schema/example-schema.json",
JSON.stringify(swaggerExampleSchemaDoc)
);
this.fileLoader.preloadExtraFile(
"http://json.schemastore.org/swagger-2.0", //DevSkim: ignore DS137138
JSON.stringify(swagger2SchemaDoc)
);
const properties = swaggerSchemaDoc.properties as any;
properties[$id] = {};
properties._filePath = {};
this.validateSwaggerSch = await this.schemaValidator.compileAsync(swaggerSchemaDoc as Schema);
}
public async validateSwaggerSpec(swaggerFilePath: string) {
const errors = await this.validateSwaggerSpecPipeline(swaggerFilePath);
return errors;
}
private async validateSwaggerSpecPipeline(swaggerFilePath: string) {
const errors: SemanticErrorDetail[] = [];
try {
const swagger = await this.loadSwagger(swaggerFilePath, errors);
if (swagger === undefined || errors.length > 0) {
return errors;
}
await this.suppressionLoader.load(swagger);
// validate x-ms-* extensions
await this.validateSwaggerSchema(swagger, errors);
if (errors.length > 0) {
return errors;
}
// compile swagger schema
const transformCtx = await this.validateCompile(swagger, errors);
await this.validateDiscriminator(transformCtx, errors);
await this.validateDefaultValue(transformCtx, swagger._filePath, errors);
await this.validateSchemaRequiredProperties(transformCtx, swagger._filePath, errors);
await this.validateOperation(swagger, errors);
} catch (e) {
const errInfo = getOavErrorMeta("INTERNAL_ERROR", { message: `${e.message}\n${e.stack}` });
errors.unshift({
code: errInfo.code,
message: errInfo.message,
url: swaggerFilePath,
});
}
return errors;
}
private async loadSwagger(swaggerFilePath: string, errors: SemanticErrorDetail[]) {
try {
const swagger = (await this.jsonLoader.load(swaggerFilePath)) as unknown as SwaggerSpec;
swagger._filePath = swaggerFilePath;
return swagger;
} catch (e) {
if (typeof e.kind === "string") {
const ex = e as ParseError;
const errInfo = getOavErrorMeta("JSON_PARSING_ERROR", { details: ex.code });
errors.push({
code: errInfo.code,
message: errInfo.message,
position: ex.position,
url: ex.url,
});
} else if (e instanceof JsonLoaderRefError) {
const errInfo = getOavErrorMeta("UNRESOLVABLE_REFERENCE", { ref: e.ref });
errors.push({
code: errInfo.code,
message: errInfo.message,
position: e.position,
url: e.url,
});
} else {
throw e;
}
return;
}
}
private async validateSwaggerSchema(swagger: SwaggerSpec, errors: SemanticErrorDetail[]) {
const result = this.validateSwaggerSch({}, swagger);
const rootInfo = getRootObjectInfo(getInfo(swagger)!);
this.addErrorsFromSchemaValidation(result, rootInfo.url, swagger, errors);
}
private addErrorsFromSchemaValidation(
result: SchemaValidateIssue[],
url: string,
rootObj: any,
errors: SemanticErrorDetail[]
) {
const existedJsonPaths: string[] = [];
for (const err of result) {
// ignore below schema errors
if (
err.code === "NOT_PASSED" &&
(err.message.includes('should match "else" schema') ||
err.message.includes('should match "then" schema'))
) {
continue;
} else if (err.code === "NOT_PASSED" && err.schemaPath === "#/additionalProperties/not") {
err.message = "path DOES NOT start with /";
}
err.jsonPathsInPayload = err.jsonPathsInPayload.filter((jsonPath) => {
let node;
/*eslint no-constant-condition: ["error", { "checkLoops": false }]*/
while (true) {
try {
node = jsonPointer.get(rootObj, jsonPathToPointer(jsonPath));
const isSuppressed = isSuppressedInPath(node, err.code, err.message);
if (!isSuppressed) {
existedJsonPaths.push(jsonPath);
}
return !isSuppressed;
} catch (e) {
let isContinue = false;
// the jsonPathsInPayload will include non-existed path, so it needs to walk back to
// exclude the unexisted path
if (e.message.includes("Invalid reference token:")) {
const token = e.message.substring("Invalid reference token:".length + 1);
const index = jsonPath.lastIndexOf(token);
if (index > 0) {
jsonPath = jsonPath.substring(0, index);
if (jsonPath.endsWith(".") || jsonPath.endsWith("/")) {
jsonPath = jsonPath.substring(0, jsonPath.length - 1);
}
isContinue = true;
}
}
// if it's not the case of containing unexisted path, then throw this error
if (isContinue === false) {
throw e;
}
}
}
});
if (err.jsonPathsInPayload.length === 0) {
continue;
}
const jsonPath = existedJsonPaths[0];
const position = getFilePositionFromJsonPath(rootObj, jsonPath);
errors.push({
code: err.code,
message: err.message,
url,
position,
jsonPath,
});
}
}
private addErrorsFromErrorCode(
errors: SemanticErrorDetail[],
url: string,
meta: ReturnType<typeof getOavErrorMeta>,
obj: any,
jsonPath?: string
) {
if (
isSuppressedInPath(obj, meta.id!, meta.message) ||
isSuppressedInPath(obj, meta.code, meta.message)
) {
return;
}
const info = getInfo(obj);
errors.push({
code: meta.code as SemanticValidationErrorCode,
message: meta.message,
url,
position: info?.position,
jsonPath: jsonPath ?? obj?.[refSelfSymbol],
});
}
private async validateCompile(spec: SwaggerSpec, errors: SemanticErrorDetail[]) {
const transformCtx = getTransformContext(this.jsonLoader, this.schemaValidator, [
xmsPathsTransformer,
resolveNestedDefinitionTransformer,
referenceFieldsTransformer,
pathRegexTransformer,
discriminatorTransformer,
allOfTransformer,
noAdditionalPropertiesTransformer,
nullableTransformer,
pureObjectTransformer,
]);
applySpecTransformers(spec, transformCtx);
applyGlobalTransformers(transformCtx);
await traverseSwaggerAsync(spec, {
onOperation: async (operation) => {
try {
await this.liveValidatorLoader.getRequestValidator(operation);
} catch (e) {
const info = getInfo(operation);
errors.push({
code: "INTERNAL_ERROR",
message: `Failed to compile validator on operation\n${operation.operationId} ${operation._method}\n${e.message}\n${e.stack}`,
url: spec._filePath,
position: info?.position,
});
}
},
onResponse: async (response, operation, _, statusCode) => {
try {
await this.liveValidatorLoader.getResponseValidator(response);
} catch (e) {
const info = getInfo(operation);
errors.push({
code: "INTERNAL_ERROR",
message: `Failed to compile validator on operation response\n${statusCode} ${operation.operationId} ${operation._method}\n${e.message}\n${e.stack}`,
url: spec._filePath,
position: info?.position,
});
}
},
});
return transformCtx;
}
private async validateDiscriminator(
transformCtx: TransformContext,
errors: SemanticErrorDetail[]
) {
const { objSchemas, jsonLoader } = transformCtx;
for (const sch of objSchemas) {
const d = sch.discriminator;
if (d === undefined) {
continue;
}
const info = getInfo(sch);
const rootInfo = getRootObjectInfo(info!);
if (sch.required?.find((x) => x === d) === undefined) {
const meta = getOavErrorMeta("DISCRIMINATOR_NOT_REQUIRED", { property: d });
this.addErrorsFromErrorCode(errors, rootInfo.url, meta, sch);
}
if (sch.properties?.[d] === undefined) {
const meta = getOavErrorMeta("OBJECT_MISSING_REQUIRED_PROPERTY_DEFINITION", {
property: d,
});
this.addErrorsFromErrorCode(errors, rootInfo.url, meta, sch);
} else {
const discriminatorProp = jsonLoader.resolveRefObj(sch.properties[d]);
if (discriminatorProp.type !== "string") {
const meta = getOavErrorMeta("INVALID_DISCRIMINATOR_TYPE", {
property: d,
});
this.addErrorsFromErrorCode(errors, rootInfo.url, meta, sch);
continue;
}
if (discriminatorProp.enum !== undefined && sch.discriminatorMap !== undefined) {
for (const childSchRef of Object.values(sch.discriminatorMap)) {
if (childSchRef === null) {
continue;
}
const childSch = jsonLoader.resolveRefObj(childSchRef);
const discriminatorValue = childSch[xmsDiscriminatorValue];
if (
discriminatorValue !== undefined &&
!discriminatorProp.enum.includes(discriminatorValue)
) {
const meta = getOavErrorMeta("INVALID_XMS_DISCRIMINATOR_VALUE", {
value: discriminatorValue,
});
const url = getRootObjectInfo(getInfo(childSch)!).url;
this.addErrorsFromErrorCode(errors, url, meta, childSch);
}
}
}
}
}
for (const sch of objSchemas) {
if (!sch._missingDiscriminator) {
continue;
}
const info = getInfo(sch);
const rootInfo = getRootObjectInfo(info!);
const meta = getOavErrorMeta("DISCRIMINATOR_PROPERTY_NOT_FOUND", {
value: sch[xmsDiscriminatorValue],
});
this.addErrorsFromErrorCode(errors, rootInfo.url, meta, sch);
}
}
private async validateDefaultValue(
transformCtx: TransformContext,
url: string,
errors: SemanticErrorDetail[]
) {
for (const sch of transformCtx.objSchemas) {
if (sch.default === undefined) {
continue;
}
const validate = await this.schemaValidator.compileAsync({
properties: {
default: sch,
},
});
const result = validate({}, sch);
this.addErrorsFromSchemaValidation(result, url, sch, errors);
}
}
private async validateSchemaRequiredProperties(
transformCtx: TransformContext,
url: string,
errors: SemanticErrorDetail[]
) {
for (const sch of transformCtx.objSchemas) {
if (sch.required === undefined) {
continue;
}
for (const name of sch.required) {
if (sch.properties?.[name] !== undefined) {
continue;
}
const meta = getOavErrorMeta("OBJECT_MISSING_REQUIRED_PROPERTY_DEFINITION", {
property: name,
});
this.addErrorsFromErrorCode(errors, url, meta, sch);
}
}
for (const sch of transformCtx.arrSchemas) {
if (sch.items !== undefined) {
continue;
}
const meta = getOavErrorMeta("OBJECT_MISSING_REQUIRED_PROPERTY_SCHEMA", {
property: "items",
});
this.addErrorsFromErrorCode(errors, url, meta, sch);
}
}
private async validateOperation(spec: SwaggerSpec, errors: SemanticErrorDetail[]) {
const visitedOperationId = new Set<string>();
const visitedPathTemplate = new Set<string>();
const url = spec._filePath;
const pathArgs = new Set<string>();
let pathParams: Parameter[] | undefined;
traverseSwagger(spec, {
onPath: (path) => {
pathArgs.clear();
pathParams = path.parameters;
const pathTemplate = path._pathTemplate;
let normalizedPath = pathTemplate;
const argMatches = normalizedPath.match(/\{.*?\}/g);
let idx = 0;
for (const arg of argMatches ?? []) {
if (arg === "{}") {
const meta = getOavErrorMeta("EMPTY_PATH_PARAMETER_DECLARATION", { pathTemplate });
this.addErrorsFromErrorCode(errors, url, meta, path);
} else {
normalizedPath = normalizedPath.replace(arg, `arg${idx}`);
++idx;
pathArgs.add(arg.substr(1, arg.length - 2));
}
}
if (visitedPathTemplate.has(normalizedPath)) {
const meta = getOavErrorMeta("EQUIVALENT_PATH", { pathTemplate });
this.addErrorsFromErrorCode(errors, url, meta, path);
}
visitedPathTemplate.add(normalizedPath);
},
onOperation: (operation) => {
let bodyParam: Parameter | undefined;
const requiredPathArgs = new Set(pathArgs);
const visitedParamName = new Set<string>();
const { operationId, parameters, consumes } = operation;
const mergedParameters = [...(parameters ?? []), ...(pathParams ?? [])];
if (operationId !== undefined) {
if (visitedOperationId.has(operationId)) {
const meta = getOavErrorMeta("DUPLICATE_OPERATIONID", { operationId });
this.addErrorsFromErrorCode(errors, url, meta, operation);
} else {
visitedOperationId.add(operationId);
}
}
for (const p of mergedParameters) {
const param = this.jsonLoader.resolveRefObj(p);
const { name } = param;
if (visitedParamName.has(name)) {
const meta = getOavErrorMeta("DUPLICATE_PARAMETER", { name });
this.addErrorsFromErrorCode(errors, url, meta, operation);
}
visitedParamName.add(name);
if (param.in === "body" || param.in === "formData") {
if (bodyParam !== undefined) {
const meta = getOavErrorMeta(
param.in === bodyParam.in
? "MULTIPLE_BODY_PARAMETERS"
: "INVALID_PARAMETER_COMBINATION",
{}
);
if (
!(
meta.code === "MULTIPLE_BODY_PARAMETERS" &&
param.in === "formData" &&
consumes !== undefined &&
consumes.includes("multipart/form-data")
)
) {
this.addErrorsFromErrorCode(errors, url, meta, operation);
}
}
bodyParam = param;
}
if (param.in === "path") {
if (!requiredPathArgs.has(name)) {
const meta = getOavErrorMeta("MISSING_PATH_PARAMETER_DECLARATION", { name });
this.addErrorsFromErrorCode(errors, url, meta, operation);
}
requiredPathArgs.delete(name);
}
}
for (const name of requiredPathArgs) {
const meta = getOavErrorMeta("MISSING_PATH_PARAMETER_DEFINITION", { name });
this.addErrorsFromErrorCode(errors, url, meta, operation);
}
},
});
}
}
// Compatible wrapper for old SemanticValidator
export class SemanticValidator {
public validator: SwaggerSemanticValidator;
public specValidationResult: {
validateSpec?: {
isValid?: boolean;
error?: unknown;
warning?: unknown;
result?: unknown;
errors?: SemanticErrorDetail[];
warnings?: unknown;
};
resolveSpec?: undefined;
validityStatus: boolean;
operations: any;
initialize?: unknown;
} = { validityStatus: true, operations: {} };
public constructor(public specPath: string, specInJson?: any) {
const container = inversifyGetContainer();
this.validator = inversifyGetInstance(SwaggerSemanticValidator, {
...defaultOpts,
container,
});
if (specInJson) {
const fileLoader = container.get(FileLoader);
fileLoader.preloadExtraFile(specPath, JSON.stringify(specInJson));
}
}
public async initialize() {
await this.validator.initialize();
// API compatible
return null as any;
}
public async validateSpec() {
const errors = await this.validator.validateSwaggerSpec(this.specPath);
this.specValidationResult.validateSpec = {
isValid: errors.length === 0,
errors,
};
this.specValidationResult.validityStatus = errors.length === 0;
return { errors, warnings: [] };
}
}