lib/util/validationError.ts (301 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
import { FilePosition, flatMap, fold, JsonRef, StringMap } from "@azure-tools/openapi-tools-common";
import { JSONPath } from "jsonpath-plus";
import _ from "lodash";
import { jsonSymbol, schemaSymbol } from "z-schema";
import { Severity } from "./severity";
/**
* @class
* Error that results from validations.
*/
interface ErrorCodeMetadata {
readonly severity: Severity;
readonly docUrl: string;
}
export type ValidationErrorMetadata = ErrorCodeMetadata & { code: ExtendedErrorCode };
export type ExtendedErrorCode = ErrorCode | WrapperErrorCode | RuntimeErrorCode;
export type ErrorCode = keyof typeof errorConstants;
export type WrapperErrorCode = keyof typeof wrapperErrorConstants;
export type RuntimeErrorCode = keyof typeof runtimeErrorConstants;
const errorConstants = {
INVALID_TYPE: {
severity: Severity.Critical,
docUrl: "",
},
INVALID_FORMAT: { severity: Severity.Critical, docUrl: "" },
ENUM_MISMATCH: { severity: Severity.Critical, docUrl: "" },
ENUM_CASE_MISMATCH: { severity: Severity.Error, docUrl: "" },
PII_MISMATCH: { severity: Severity.Warning, docUrl: "" },
NOT_PASSED: { severity: Severity.Critical, docUrl: "" },
ARRAY_LENGTH_SHORT: { severity: Severity.Critical, docUrl: "" },
ARRAY_LENGTH_LONG: { severity: Severity.Critical, docUrl: "" },
ARRAY_UNIQUE: { severity: Severity.Critical, docUrl: "" },
ARRAY_ADDITIONAL_ITEMS: {
severity: Severity.Critical,
docUrl: "",
},
MULTIPLE_OF: { severity: Severity.Critical, docUrl: "" },
MINIMUM: { severity: Severity.Critical, docUrl: "" },
MINIMUM_EXCLUSIVE: { severity: Severity.Critical, docUrl: "" },
MAXIMUM: { severity: Severity.Critical, docUrl: "" },
MAXIMUM_EXCLUSIVE: { severity: Severity.Critical, docUrl: "" },
READONLY_PROPERTY_NOT_ALLOWED_IN_REQUEST: {
severity: Severity.Critical,
docUrl: "",
},
UNRESOLVABLE_REFERENCE: { severity: Severity.Critical, docUrl: "" },
SECRET_PROPERTY: { severity: Severity.Critical, docUrl: "" },
WRITEONLY_PROPERTY_NOT_ALLOWED_IN_RESPONSE: { severity: Severity.Critical, docUrl: "" },
OBJECT_PROPERTIES_MINIMUM: {
severity: Severity.Critical,
docUrl: "",
},
OBJECT_PROPERTIES_MAXIMUM: {
severity: Severity.Critical,
docUrl: "",
},
OBJECT_MISSING_REQUIRED_PROPERTY: {
severity: Severity.Critical,
docUrl: "",
},
MISSING_REQUIRED_PARAMETER: {
severity: Severity.Critical,
docUrl: "",
},
OBJECT_ADDITIONAL_PROPERTIES: {
severity: Severity.Critical,
docUrl: "",
},
OBJECT_DEPENDENCY_KEY: { severity: Severity.Warning, docUrl: "" },
MIN_LENGTH: { severity: Severity.Critical, docUrl: "" },
MAX_LENGTH: { severity: Severity.Critical, docUrl: "" },
PATTERN: { severity: Severity.Critical, docUrl: "" },
INVALID_RESPONSE_CODE: { severity: Severity.Critical, docUrl: "" },
INVALID_CONTENT_TYPE: { severity: Severity.Error, docUrl: "" },
DISCRIMINATOR_VALUE_NOT_FOUND: { severity: Severity.Critical, docUrl: "" },
INVALID_RESPONSE_HEADER: { severity: Severity.Error, docUrl: "" },
INVALID_RESPONSE_BODY: { severity: Severity.Critical, docUrl: "" },
MISSING_RESOURCE_ID: { severity: Severity.Critical, docUrl: "" },
LRO_RESPONSE_CODE: { severity: Severity.Critical, docUrl: "" },
LRO_RESPONSE_HEADER: { severity: Severity.Critical, docUrl: "" },
};
const wrapperErrorConstants = {
ANY_OF_MISSING: { severity: Severity.Critical, docUrl: "" },
ONE_OF_MISSING: { severity: Severity.Critical, docUrl: "" },
ONE_OF_MULTIPLE: { severity: Severity.Critical, docUrl: "" },
MULTIPLE_OPERATIONS_FOUND: {
severity: Severity.Critical,
docUrl: "",
},
INVALID_RESPONSE_HEADER: {
severity: Severity.Critical,
docUrl: "",
},
INVALID_RESPONSE_BODY: { severity: Severity.Critical, docUrl: "" },
INVALID_REQUEST_PARAMETER: {
severity: Severity.Critical,
docUrl: "",
},
};
const runtimeErrorConstants = {
OPERATION_NOT_FOUND_IN_CACHE: {
severity: Severity.Critical,
docUrl: "",
},
OPERATION_NOT_FOUND_IN_CACHE_WITH_VERB: {
severity: Severity.Critical,
docUrl: "",
},
OPERATION_NOT_FOUND_IN_CACHE_WITH_API: {
severity: Severity.Critical,
docUrl: "",
},
OPERATION_NOT_FOUND_IN_CACHE_WITH_PROVIDER: {
severity: Severity.Critical,
docUrl: "",
},
INTERNAL_ERROR: { severity: Severity.Critical, docUrl: "" },
};
export const allErrorConstants = {
...errorConstants,
...wrapperErrorConstants,
...runtimeErrorConstants,
};
/**
* Gets the validation error metadata from an error code. If the code is unknown assume critical.
*/
export const errorCodeToErrorMetadata = (code: ExtendedErrorCode): ValidationErrorMetadata => ({
...(allErrorConstants[code] || {
severity: Severity.Critical,
docUrl: "",
}),
code,
});
export interface SourceLocation {
readonly url: string;
readonly jsonRef?: string;
readonly jsonPath?: string;
readonly position: {
readonly column: number;
readonly line: number;
};
}
export interface RuntimeException {
code: string;
readonly message: string;
}
export interface NodeError<T extends NodeError<T>> {
code?: string;
message?: string;
path?: string | string[];
jsonPath?: string;
schemaPath?: string;
similarPaths?: string[];
similarJsonPaths?: string[];
errors?: T[];
innerErrors?: T[];
in?: string;
name?: string;
params?: unknown[];
inner?: T[];
title?: string;
position?: FilePosition;
url?: string;
jsonPosition?: FilePosition;
jsonUrl?: string;
directives?: StringMap<unknown>;
readonly [jsonSymbol]?: JsonRef;
readonly [schemaSymbol]?: any;
}
export interface ValidationResult<T extends NodeError<T>> {
readonly requestValidationResult: T;
readonly responseValidationResult: T;
}
/**
* Serializes error tree
*/
export function serializeErrors<T extends NodeError<T>>(node: T, path: string[]): T[] {
if (isLeaf(node)) {
if (isTrueError(node)) {
setPathProperties(node, path);
return [node];
}
return [];
}
if (node.path) {
// in this case the path will be set to the url instead of the path to the property
if (node.code === "INVALID_REQUEST_PARAMETER" && node.in === "body") {
node.path = [];
} else if (
(node.in === "query" || node.in === "path") &&
node.path[0] === "paths" &&
node.name
) {
// in this case we will want to normalize the path with the uri and the paramter name
node.path = [node.path[1], node.name];
}
path = consolidatePath(path, node.path);
}
const serializedErrors = flatMap(node.errors, (validationError) =>
serializeErrors(validationError, path)
).toArray();
const serializedInner = fold(
node.inner,
(acc, validationError) => {
const errs = serializeErrors(validationError, path);
errs.forEach((err) => {
const similarErr = acc.find((el) => areErrorsSimilar(err, el));
if (similarErr && similarErr.path) {
if (!similarErr.similarPaths) {
similarErr.similarPaths = [];
}
similarErr.similarPaths.push(err.path as string);
if (!similarErr.similarJsonPaths) {
similarErr.similarJsonPaths = [];
}
similarErr.similarJsonPaths.push(err.jsonPath as string);
} else {
acc.push(err);
}
});
return acc;
},
new Array<T>()
);
if (isDiscriminatorError(node)) {
setPathProperties(node, path);
node.inner = serializedInner;
return [node];
}
return [...serializedErrors, ...serializedInner];
}
/**
* Sets the path and jsonPath properties on an error node.
*/
function setPathProperties<T extends NodeError<T>>(node: T, path: string[]) {
if (!node.path) {
return;
}
let nodePath = typeof node.path === "string" ? [node.path] : node.path;
if (
node.code === "OBJECT_MISSING_REQUIRED_PROPERTY" ||
node.code === "OBJECT_ADDITIONAL_PROPERTIES"
) {
// For multiple missing/additional properties , each node would only contain one param.
if (node.params && node.params.length > 0) {
nodePath = nodePath.concat(node.params[0] as string);
}
}
const pathSegments = consolidatePath(path, nodePath);
pathSegments.unshift("$");
node.path = pathSegments.join("/");
node.jsonPath = (pathSegments.length && (JSONPath as any).toPathString(pathSegments)) || "";
}
/**
* Checks if two errors are the same except their path.
*/
function areErrorsSimilar<T extends NodeError<T>>(node1: T, node2: T) {
if (
node1.code !== node2.code ||
node1.title !== node2.title ||
node1.message !== node2.message ||
!arePathsSimilar(node1.path, node2.path)
) {
return false;
}
if (!node1.inner && !node2.inner) {
return true;
}
if (!node1.inner || !node2.inner || node1.inner.length !== node2.inner.length) {
return false;
}
for (let i = 0; i < node1.inner.length; i++) {
if (!areErrorsSimilar(node1.inner[i], node2.inner[i])) {
return false;
}
}
return true;
}
/**
* Checks if paths differ only in indexes
*/
const arePathsSimilar = (
path1: string | string[] | undefined,
path2: string | string[] | undefined
) => {
if (path1 === path2) {
return true;
}
if (path1 === undefined || path2 === undefined) {
return false;
}
const p1 = Array.isArray(path1) ? path1 : path1.split("/");
const p2 = Array.isArray(path2) ? path2 : path2.split("/");
return _.xor(p1, p2).every((v) => Number.isInteger(+v));
};
const isDiscriminatorError = <T extends NodeError<T>>(node: T) =>
node.code === "ONE_OF_MISSING" && node.inner && node.inner.length > 0;
const isTrueError = <T extends NodeError<T>>(node: T): boolean =>
// this is necessary to filter out extra errors coming from doing the ONE_OF transformation on
// the models to allow "null"
!(node.code === "INVALID_TYPE" && node.params && node.params[0] === "null");
const isLeaf = <T extends NodeError<T>>(node: T): boolean => !node.errors && !node.inner;
/**
* Unifies a suffix path with a root path.
*/
function consolidatePath(path: string[], suffixPath: string | string[]): string[] {
let newSuffixIndex = 0;
let overlapIndex = path.lastIndexOf(suffixPath[newSuffixIndex]);
let previousIndex = overlapIndex;
if (overlapIndex === -1) {
return path.concat(suffixPath);
}
for (newSuffixIndex = 1; newSuffixIndex < suffixPath.length; ++newSuffixIndex) {
previousIndex = overlapIndex;
overlapIndex = path.lastIndexOf(suffixPath[newSuffixIndex]);
if (overlapIndex === -1 || overlapIndex !== previousIndex + 1) {
break;
}
}
let newPath: string[] = [];
if (newSuffixIndex === suffixPath.length) {
// if all elements are contained in the existing path, nothing to do.
newPath = path.slice(0);
} else if (overlapIndex === -1 && previousIndex === path.length - 1) {
// if we didn't find element at x in the previous path and element at x -1 is the last one in
// the path, append everything from x
newPath = path.concat(suffixPath.slice(newSuffixIndex));
} else {
// otherwise it is not contained at all, so concat everything.
newPath = path.concat(suffixPath);
}
return newPath;
}