oav/lib/swaggerValidator/ajvSchemaValidator.ts

593 строки
18 KiB
TypeScript

import * as lodash from "lodash";
import {
ChildObjectInfo,
getInfo,
getRootObjectInfo,
RootObjectInfo,
} from "@azure-tools/openapi-tools-common";
import { Ajv, default as ajvInit, ErrorObject, ValidateFunction } from "ajv";
import { inject, injectable } from "inversify";
import { TYPES } from "../inversifyUtils";
import { $id, JsonLoader } from "../swagger/jsonLoader";
import { isSuppressed } from "../swagger/suppressionLoader";
import { refSelfSymbol, Schema, SwaggerSpec } from "../swagger/swaggerTypes";
import { getNameFromRef } from "../transform/context";
import {
xmsAzureResource,
xmsEnum,
xmsMutability,
xmsReadonlyRef,
xmsSecret,
} from "../util/constants";
import { getOavErrorMeta, TrafficValidationErrorCode } from "../util/errorDefinitions";
import { Severity } from "../util/severity";
import { Writable } from "../util/utils";
import { SourceLocation } from "../util/validationError";
import { ajvEnableAll, ajvEnableArmRule, ajvEnableArmIdFormat } from "./ajv";
import {
getIncludeErrorsMap,
SchemaValidateContext,
SchemaValidateFunction,
SchemaValidateIssue,
SchemaValidator,
SchemaValidatorOption,
} from "./schemaValidator";
@injectable()
export class AjvSchemaValidator implements SchemaValidator {
private ajv: Ajv;
public constructor(
loader: JsonLoader,
@inject(TYPES.opts) schemaValidatorOption?: SchemaValidatorOption
) {
this.ajv = ajvInit({
// tslint:disable-next-line: no-submodule-imports
meta: require("ajv/lib/refs/json-schema-draft-04.json"),
schemaId: "auto",
extendRefs: "fail",
format: "full",
missingRefs: true,
addUsedSchema: false,
removeAdditional: false,
nullable: true,
allErrors: true,
messages: true,
verbose: true,
inlineRefs: false,
passContext: true,
loopRequired: 2,
unknownFormats: "ignore",
loadSchema: async (uri) => {
const spec: SwaggerSpec = await loader.resolveFile(uri);
return { [$id]: spec[$id], definitions: spec.definitions, parameters: spec.parameters };
},
});
ajvEnableAll(this.ajv, loader);
// always enable the armId format validation
ajvEnableArmIdFormat(this.ajv);
if (schemaValidatorOption?.isArmCall === true) {
ajvEnableArmRule(this.ajv);
}
}
public async compileAsync(schema: Schema): Promise<SchemaValidateFunction> {
const validate = await this.ajv.compileAsync(schema);
return this.getValidateFunction(validate);
}
public compile(schema: Schema): SchemaValidateFunction {
const validate = this.ajv.compile(schema);
return this.getValidateFunction(validate);
}
private getValidateFunction(validate: ValidateFunction) {
const ret = function validateSchema(ctx: SchemaValidateContext, data: any) {
const result: SchemaValidateIssue[] = [];
const isValid = validateSchema.validate.call(ctx, data);
if (!isValid) {
const errors = ReValidateIfNeed(
validateSchema.validate.errors!,
ctx,
data,
validateSchema.validate
);
if (errors.length > 0) {
ajvErrorListToSchemaValidateIssueList(errors, ctx, result);
}
validateSchema.validate.errors = null;
}
return result;
};
ret.validate = validate;
return ret;
}
}
export const ajvErrorListToSchemaValidateIssueList = (
errors: ErrorObject[],
ctx: SchemaValidateContext,
result: SchemaValidateIssue[]
) => {
const includeErrorsSet = getIncludeErrorsMap(ctx.includeErrors);
const similarIssues: Map<string, SchemaValidateIssue> = new Map();
for (const error of errors) {
const issue = ajvErrorToSchemaValidateIssue(error, ctx);
if (
issue === undefined ||
(includeErrorsSet !== undefined && !includeErrorsSet.has(issue.code))
) {
continue;
}
const issueHashKey = [
issue.code,
issue.message,
issue.source.url,
issue.source.position.column.toString(),
issue.source.position.line.toString(),
].join("|");
const similarIssue = similarIssues.get(issueHashKey);
if (similarIssue === undefined) {
similarIssues.set(issueHashKey, issue);
result.push(issue);
continue;
}
similarIssue.jsonPathsInPayload.push(issue.jsonPathsInPayload[0]);
}
for (const issue of result) {
if (issue.jsonPathsInPayload.length > 1) {
(issue as any).jsonPathsInPayload = [...new Set(issue.jsonPathsInPayload)];
}
}
};
export const sourceMapInfoToSourceLocation = (
info?: ChildObjectInfo | RootObjectInfo
): Writable<SourceLocation> => {
return info === undefined
? {
url: "",
position: { line: -1, column: -1 },
}
: {
url: getRootObjectInfo(info).url,
position: { line: info.position.line, column: info.position.column },
};
};
export const ajvErrorToSchemaValidateIssue = (
err: ErrorObject,
ctx: SchemaValidateContext
): SchemaValidateIssue | undefined => {
const { parentSchema, params } = err;
let { schema } = err;
if (shouldSkipError(err, ctx)) {
return undefined;
}
let dataPath = err.dataPath;
const extraDataPath =
(params as any).additionalProperty ??
(params as any).missingProperty ??
(parentSchema as Schema).discriminator;
if (extraDataPath !== undefined) {
dataPath = `${dataPath}.${extraDataPath}`;
if (schema[extraDataPath] !== undefined) {
schema = schema[extraDataPath];
}
}
const errInfo = ajvErrorCodeToOavErrorCode(err, ctx);
if (errInfo === undefined) {
return undefined;
}
if (isSuppressed(err.parentSchema, errInfo.code, errInfo.message)) {
return undefined;
}
let sch: Schema | undefined = parentSchema;
let info = getInfo(sch);
if (info === undefined) {
sch = schema;
info = getInfo(sch);
}
const source = sourceMapInfoToSourceLocation(info);
if (sch?.[refSelfSymbol] !== undefined) {
source.jsonRef = sch[refSelfSymbol]!.substr(sch[refSelfSymbol]!.indexOf("#"));
}
const result = errInfo as SchemaValidateIssue;
result.jsonPathsInPayload = [dataPath];
result.schemaPath = err.schemaPath;
result.source = source;
return result;
};
const ReValidateIfNeed = (
originalErrors: ErrorObject[],
ctx: SchemaValidateContext,
data: any,
validate: ValidateFunction
): ErrorObject[] => {
const result: ErrorObject[] = [];
const newData = lodash.cloneDeep(data);
for (const originalError of originalErrors) {
validate.errors = null;
const { schema, parentSchema: parentSch, keyword, data: errorData, dataPath } = originalError;
const parentSchema = parentSch as Schema;
// If the value of query parameter is in string format, we can revalidate this error
if (
!ctx.isResponse &&
keyword === "type" &&
schema === "array" &&
typeof errorData === "string" &&
(parentSchema as any)?.["in"] === "query"
) {
const arrayData = errorData.split(",").map((item) => {
// when item is number
const numberRegex = /^[+-]?\d+(\.\d+)?([Ee]\+?\d+)?$/g;
if (numberRegex.test(item)) {
return parseFloat(item);
}
// when item is boolean
if (item === "true" || item === "false") {
return item === "true";
}
return item;
});
const position = dataPath.substr(1);
lodash.set(newData, position, arrayData);
const isValid = validate.call(ctx, newData);
if (!isValid) {
// if validate.errors have new errors, add them to result
for (const newError of validate.errors!) {
let [includedInResult, includedInOriginalErrors] = [false, false];
for (const resultError of result) {
if (lodash.isEqual(newError, resultError)) {
// error is included in result
includedInResult = true;
break;
}
}
if (!includedInResult) {
for (const eachOriginalError of originalErrors) {
if (lodash.isEqual(newError, eachOriginalError)) {
// error is included in originalErrors
includedInOriginalErrors = true;
break;
}
}
if (!includedInOriginalErrors) {
result.push(newError);
}
}
}
}
continue;
}
result.push(originalError);
}
return result;
};
const shouldSkipError = (error: ErrorObject, cxt: SchemaValidateContext) => {
const { parentSchema: parentSch, params, keyword } = error;
const parentSchema = parentSch as Schema;
// If schema has allof property, we can get schema in "_realschema", so is data
const schema = Object.keys(error).includes("_realSchema")
? (error as any)._realSchema
: error.schema;
const data = Object.keys(error).includes("_realData") ? (error as any)._realData : error.data;
if (schema?._skipError || parentSchema._skipError) {
return true;
}
// If we're erroring on the added property refWithReadOnly simply ignore the error
if (
error.keyword === "additionalProperties" &&
(params as any).additionalProperty === "refWithReadOnly"
) {
return true;
}
// If a response has x-ms-mutability property and its missing the read we can skip this error
if (
cxt.isResponse &&
((keyword === "required" &&
(parentSchema.properties?.[(params as any).missingProperty]?.[xmsMutability]?.indexOf(
"read"
) === -1 ||
// required check is ignored when x-ms-secret is true
(parentSchema.properties?.[(params as any).missingProperty] as any)?.[xmsSecret] ===
true)) ||
(keyword === "type" && data === null && parentSchema[xmsMutability]?.indexOf("read") === -1))
) {
return true;
}
// If a request is missing a required property that is readOnly we can skip this error
if (
!cxt.isResponse &&
keyword === "required" &&
(parentSchema.properties?.[(params as any).missingProperty]?.[xmsReadonlyRef] ||
parentSchema.properties?.[(params as any).missingProperty]?.readOnly)
) {
return true;
}
// If a response has property which x-ms-secret value is "true" in post we can skip this error
if (
cxt.isResponse &&
(cxt as any)?.httpMethod === "post" &&
// should skip error when x-ms-secret is "true"
((keyword === "x-ms-secret" && (parentSchema as any)?.[xmsSecret] === true) ||
// should skip error when x-ms-secret is "true" and x-ms-mutability is "create" and "update"
(keyword === "x-ms-mutability" &&
(parentSchema as any)?.[xmsSecret] === true &&
parentSchema[xmsMutability]?.indexOf("read") === -1))
) {
return true;
}
// If payload has property with date-time parameter and its value is valid except missing "Z" in the end we can skip this error
if (keyword === "format" && schema === "date-time" && typeof data === "string") {
const reg = /^\d+-(0\d|1[0-2])-([0-2]\d|3[01])T([01]\d|2[0-3]):[0-5][0-9]:[0-5][0-9]/;
// intercept time, example: 2008-09-22T14:01:54
const time = data.slice(0, 19);
if (reg.test(time)) {
const dateZ = new Date(data + "Z").toUTCString();
// validate hour
const ifHoursAreSame = time.slice(11, 13) === dateZ.slice(17, 19);
// validate day for leap year, example: 2008-02-29
const ifDaysAreSame = time.slice(8, 10) === dateZ.slice(5, 7);
return ifHoursAreSame && ifDaysAreSame;
}
}
// If a response data has multipleOf property, and it divided by multipleOf value is an integer, we can skip this error
if (keyword === "multipleOf" && typeof schema === "number" && typeof data === "number") {
let [newSchema, newData] = [schema, data];
while (newSchema < 1) {
newSchema *= 10;
newData *= 10;
}
const result = newData / newSchema;
// should skip error when response data divided by multipleOf value is an integer
return result === parseInt(String(result));
}
return false;
};
const errorKeywordsMapping: { [key: string]: TrafficValidationErrorCode } = {
additionalProperties: "OBJECT_ADDITIONAL_PROPERTIES",
required: "OBJECT_MISSING_REQUIRED_PROPERTY",
format: "INVALID_FORMAT",
type: "INVALID_TYPE",
pattern: "PATTERN",
minimum: "MINIMUM",
maximum: "MAXIMUM",
exclusiveMinimum: "MINIMUM_EXCLUSIVE",
exclusiveMaximum: "MAXIMUM_EXCLUSIVE",
minLength: "MIN_LENGTH",
maxLength: "MAX_LENGTH",
maxItems: "ARRAY_LENGTH_LONG",
minItems: "ARRAY_LENGTH_SHORT",
maxProperties: "OBJECT_PROPERTIES_MAXIMUM",
minProperties: "OBJECT_PROPERTIES_MINIMUM",
uniqueItems: "ARRAY_UNIQUE",
additionalItems: "ARRAY_ADDITIONAL_ITEMS",
anyOf: "ANY_OF_MISSING",
dependencies: "OBJECT_DEPENDENCY_KEY",
multiple: "MULTIPLE_OF",
discriminatorMap: "DISCRIMINATOR_VALUE_NOT_FOUND",
[xmsAzureResource]: "MISSING_RESOURCE_ID",
};
// Should be type "never" to ensure we've covered all the errors
// export type MissingErrorCode = Exclude<
// ExtendedErrorCode,
// | keyof typeof validateErrorMessages
// | "PII_MISMATCH" // Used in openapi-validate
// | "INTERNAL_ERROR" // Used in liveValidator
// | "UNRESOLVABLE_REFERENCE"
// | "NOT_PASSED" // If keyword mapping not found then we use this error
// | "OPERATION_NOT_FOUND_IN_CACHE_WITH_PROVIDER" // Covered by liveValidator
// | "OPERATION_NOT_FOUND_IN_CACHE_WITH_API"
// | "OPERATION_NOT_FOUND_IN_CACHE_WITH_VERB"
// | "OPERATION_NOT_FOUND_IN_CACHE"
// | "MULTIPLE_OPERATIONS_FOUND"
// | "INVALID_RESPONSE_HEADER"
// | "INVALID_REQUEST_PARAMETER"
// >;
const transformParamsKeyword = new Set([
"pattern",
"additionalProperties",
"type",
"format",
"multipleOf",
"required",
xmsAzureResource,
]);
const transformReverseParamsKeyword = new Set([
"minLength",
"maxLength",
"maxItems",
"minItems",
"maxProperties",
"minProperties",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"discriminatorMap",
]);
interface MetaErr {
code: string;
message: string;
severity: Severity;
params?: any;
}
export const ajvErrorCodeToOavErrorCode = (
error: ErrorObject,
ctx: SchemaValidateContext
): MetaErr | undefined => {
const { keyword, parentSchema: parentSch } = error;
const parentSchema = parentSch as Schema | undefined;
let { params, data } = error;
let result: MetaErr | undefined = {
code: "NOT_PASSED",
message: error.message!,
severity: Severity.Verbose,
};
// Workaround for incorrect ajv behavior.
// See https://github.com/ajv-validator/ajv/blob/v6/lib/dot/custom.jst#L74
if ((error as any)._realData !== undefined) {
data = (error as any)._realData;
}
switch (keyword) {
case "enum":
const { allowedValues } = params as any;
result =
data === null && parentSchema?.nullable
? undefined
: isEnumCaseMismatch(data, allowedValues)
? getOavErrorMeta("ENUM_CASE_MISMATCH", { data })
: parentSchema?.[xmsEnum]?.modelAsString
? undefined
: getOavErrorMeta("ENUM_MISMATCH", { data });
params = [data, allowedValues];
break;
case "readOnly":
case xmsMutability:
case xmsSecret:
const param = {
key: getNameFromRef(parentSchema),
value: Array.isArray(data) ? data.join(",") : JSON.stringify(data),
};
params = [param.key, null];
result =
keyword === xmsSecret
? getOavErrorMeta("SECRET_PROPERTY", param)
: ctx.isResponse
? getOavErrorMeta("WRITEONLY_PROPERTY_NOT_ALLOWED_IN_RESPONSE", param)
: getOavErrorMeta("READONLY_PROPERTY_NOT_ALLOWED_IN_REQUEST", param);
break;
case "oneOf":
result =
(params as any).passingSchemas === null
? getOavErrorMeta("ONE_OF_MISSING", {})
: getOavErrorMeta("ONE_OF_MULTIPLE", {});
params = [];
break;
case "type":
(params as any).type = (params as any).type.replace(",null", "");
data = schemaType(data);
break;
case "discriminatorMap":
data = (params as any).discriminatorValue;
break;
case "maxLength":
case "minLength":
case "maxItems":
case "minItems":
data = data.length;
break;
case "maxProperties":
case "minProperties":
data = Object.keys(data).length;
break;
case "minimum":
case "maximum":
case "exclusiveMinimum":
case "exclusiveMaximum":
params = { limit: (params as any).limit };
break;
}
const code = errorKeywordsMapping[keyword];
if (code !== undefined) {
result = getOavErrorMeta(code, { ...params, data });
}
if (transformParamsKeyword.has(keyword)) {
params = Object.values(params);
if (typeof data !== "object") {
(params as any).push(data);
}
} else if (transformReverseParamsKeyword.has(keyword)) {
params = Object.values(params);
(params as any[]).unshift(data);
}
if (result !== undefined) {
result.params = params;
}
return result;
};
const isEnumCaseMismatch = (data: string, enumList: Array<string | number>) => {
if (typeof data !== "string") {
return false;
}
data = data.toLowerCase();
for (const val of enumList) {
if (typeof val === "string" && val.toLowerCase() === data) {
return true;
}
}
return false;
};
const schemaType = (what: any): string => {
const to = typeof what;
if (to === "object") {
if (what === null) {
return "null";
}
if (Array.isArray(what)) {
return "array";
}
return "object"; // typeof what === 'object' && what === Object(what) && !Array.isArray(what);
}
if (to === "number") {
if (Number.isFinite(what)) {
if (what % 1 === 0) {
return "integer";
} else {
return "number";
}
}
if (Number.isNaN(what)) {
return "not-a-number";
}
return "unknown-number";
}
return to; // undefined, boolean, string, function
};