lib/liveValidation/operationValidator.ts (443 lines of code) (raw):
import { ParsedUrlQuery } from "querystring";
import {
FilePosition,
getInfo,
MutableStringMap,
StringMap,
} from "@azure-tools/openapi-tools-common";
import {
LoggingFn,
LowerHttpMethods,
Operation,
Response,
TransformFn,
} from "../swagger/swaggerTypes";
import { sourceMapInfoToSourceLocation } from "../swaggerValidator/ajvSchemaValidator";
import { SchemaValidateContext, SchemaValidateIssue } from "../swaggerValidator/schemaValidator";
import { jsonPathToPointer } from "../util/jsonUtils";
import { Writable } from "../util/utils";
import { SourceLocation } from "../util/validationError";
import { extractPathParamValue } from "../transform/pathRegexTransformer";
import {
ApiValidationErrorCode,
getOavErrorMeta,
TrafficValidationErrorCode,
} from "../util/errorDefinitions";
import {
LiveValidationIssue,
LiveValidatorLoggingLevels,
LiveValidatorLoggingTypes,
} from "./liveValidator";
import { LiveValidatorLoader } from "./liveValidatorLoader";
import { OperationMatch } from "./operationSearcher";
export interface ValidationRequest {
providerNamespace: string;
resourceType?: string;
apiVersion: string;
requestMethod?: LowerHttpMethods;
host?: string;
pathStr?: string;
query?: ParsedUrlQuery;
correlationId?: string;
activityId?: string;
requestUrl?: string;
specName?: string;
}
export interface OperationContext {
operationId: string;
apiVersion: string;
operationMatch?: OperationMatch;
validationRequest?: ValidationRequest;
position?: FilePosition | undefined;
}
export interface LiveRequest {
query?: ParsedUrlQuery;
readonly url: string;
readonly method: string;
headers?: { [propertyName: string]: string };
body?: StringMap<unknown>;
}
export interface LiveResponse {
statusCode: string;
headers?: { [propertyName: string]: string };
body?: StringMap<unknown>;
}
export const validateSwaggerLiveRequest = async (
request: LiveRequest,
operationContext: OperationContext,
loader?: LiveValidatorLoader,
includeErrors?: ApiValidationErrorCode[],
isArmCall?: boolean,
logging?: LoggingFn
) => {
const { operation } = operationContext.operationMatch!;
const { body, query } = request;
const result: LiveValidationIssue[] = [];
let validate = operation._validate;
if (validate === undefined) {
if (loader === undefined) {
throw new Error("Loader is undefined but request validator isn't built yet");
}
const startTimeToBuild = Date.now();
validate = await loader.getRequestValidator(operation);
const elapsedTime = Date.now() - startTimeToBuild;
if (logging) {
logging(
`On-demand build request validator with DurationInMs:${elapsedTime}`,
LiveValidatorLoggingLevels.debug,
LiveValidatorLoggingTypes.trace,
"Oav.OperationValidator.validateSwaggerLiveRequest.loader.getRequestValidator",
undefined,
operationContext.validationRequest
);
logging(
`On-demand build request validator`,
LiveValidatorLoggingLevels.info,
LiveValidatorLoggingTypes.perfTrace,
"Oav.OperationValidator.validateSwaggerLiveRequest.loader.getRequestValidator",
elapsedTime,
operationContext.validationRequest
);
}
}
const pathParam = extractPathParamValue(operationContext.operationMatch!);
transformMapValue(pathParam, operation._pathTransform);
transformMapValue(query, operation._queryTransform);
const headers = transformLiveHeader(request.headers ?? {}, operation);
validateContentType(operation.consumes!, headers, true, result);
// for rpaas calls, temp solution to log invalid_type errors for additional properties
// rather than returning the error to rpaas
const ctx = { isResponse: false, includeErrors: includeErrors as any };
const errors = validate(ctx, {
path: pathParam,
body: transformBodyValue(body, operation),
headers,
query,
});
schemaValidateIssueToLiveValidationIssue(
errors,
operation,
ctx,
result,
operationContext,
isArmCall,
logging,
body
);
return result;
};
export const validateSwaggerLiveResponse = async (
response: LiveResponse,
operationContext: OperationContext,
loader?: LiveValidatorLoader,
includeErrors?: ApiValidationErrorCode[],
isArmCall?: boolean,
logging?: LoggingFn
) => {
const { operation } = operationContext.operationMatch!;
const { statusCode, body } = response;
const rspDef = operation.responses;
const result: LiveValidationIssue[] = [];
let rsp = rspDef[statusCode];
const realCode = parseInt(statusCode, 10);
if (rsp === undefined && 400 <= realCode && realCode <= 599) {
rsp = rspDef.default;
}
if (rsp === undefined) {
result.push(issueFromErrorCode("INVALID_RESPONSE_CODE", { statusCode }, rspDef));
return result;
}
let validate = rsp._validate;
if (validate === undefined) {
if (loader === undefined) {
throw new Error("Loader is undefined but request validator isn't built yet");
}
const startTimeToBuild = Date.now();
validate = await loader.getResponseValidator(rsp);
const elapsedTime = Date.now() - startTimeToBuild;
if (logging) {
logging(
`On-demand build response validator with DurationInMs:${elapsedTime}`,
LiveValidatorLoggingLevels.debug,
LiveValidatorLoggingTypes.trace,
"Oav.OperationValidator.validateSwaggerLiveResponse.loader.getResponseValidator",
undefined,
operationContext.validationRequest
);
logging(
`On-demand build request validator`,
LiveValidatorLoggingLevels.info,
LiveValidatorLoggingTypes.perfTrace,
"Oav.OperationValidator.validateSwaggerLiveResponse.loader.getResponseValidator",
elapsedTime,
operationContext.validationRequest
);
}
}
const headers = transformLiveHeader(response.headers ?? {}, rsp);
if (rsp.schema !== undefined) {
validateContentType(operation.produces!, headers, false, result);
if (isArmCall && realCode >= 200 && realCode < 300) {
validateLroOperation(operation, statusCode, headers, result);
}
}
const ctx = {
isResponse: true,
includeErrors: includeErrors as any,
statusCode,
httpMethod: operation._method,
};
const errors = validate(ctx, {
headers,
body,
});
schemaValidateIssueToLiveValidationIssue(
errors,
operation,
ctx,
result,
operationContext,
isArmCall,
logging,
body
);
return result;
};
export const transformBodyValue = (body: any, operation: Operation): any => {
return operation._bodyTransform === undefined ? body : operation._bodyTransform(body);
};
export const transformLiveHeader = (
headers: StringMap<string>,
it: Operation | Response
): StringMap<string> => {
const result: MutableStringMap<string> = {};
for (const headerName of Object.keys(headers)) {
result[headerName.toLowerCase()] = headers[headerName];
}
transformMapValue(result, it._headerTransform);
return result;
};
export const transformMapValue = (
data?: MutableStringMap<string | number | boolean | Array<string | number | boolean>>,
transforms?: StringMap<TransformFn>
) => {
if (transforms === undefined || data === undefined) {
return;
}
for (const key of Object.keys(transforms)) {
const transform = transforms[key]!;
const val = data[key];
if (typeof val === "string") {
data[key] = transform(val);
} else if (Array.isArray(val)) {
data[key] = val.map(transform as any);
}
}
};
const validateContentType = (
allowedContentTypes: string[],
headers: StringMap<string>,
isRequest: boolean,
result: LiveValidationIssue[]
) => {
const contentType =
headers["content-type"]?.split(";")[0] || (isRequest ? undefined : "application/octet-stream");
if (contentType !== undefined && !allowedContentTypes.includes(contentType)) {
// in some cases, produces value could have colon in type like 'application/json;odata=minimalmetadata'
for (const allowedContentType of allowedContentTypes) {
if (allowedContentType.includes(";")) {
const subAllowedContentType = allowedContentType.split(";")[0];
if (subAllowedContentType.includes(contentType)) {
return;
}
}
}
result.push(
issueFromErrorCode("INVALID_CONTENT_TYPE", {
contentType,
supported: allowedContentTypes.join(", "),
})
);
}
};
/**
* Finds the resource ID for a given JSON path. Example inputs:
* - $.properties.lastname
* - $.properties.groups[0].variable
* @param bodyPayload The full payload object.
* @param jsonPath The JSON path referring to the problematic property.
* @returns The resource ID, or undefined if not found.
*/
const findResourceId = (bodyPayload: any, jsonPath: string): string | undefined => {
// schemaValidateIssueToLiveValidationIssue will provide a valid jsonPath to this function
const keys = jsonPath
.replace(/^\$\./, "") // Remove the leading "$."
.split(/\.|\[(\d+)\]/) // Split by dots or array brackets
.filter((key) => key !== undefined && key !== "");
let current: any = bodyPayload;
const stack: any[] = [];
for (const key of keys) {
if (current && typeof current === "object") {
stack.push(current);
if (Array.isArray(current)) {
// Handle array indices
const index = parseInt(key, 10);
if (!isNaN(index) && index < current.length) {
current = current[index];
} else {
return undefined;
}
} else if (key in current) {
current = current[key];
} else {
return undefined;
}
} else {
return undefined;
}
}
// notice we only ever check for parent id. This means that we will never accidentally grab the value of an id
// that has been added erroneously to the payload. (for instance if payload is to an id field that SHOULD NOT be set.)
for (let i = stack.length - 1; i >= 0; i--) {
const parent = stack[i];
if (parent && typeof parent === "object" && "id" in parent) {
return parent.id;
}
}
return undefined; // No `id` field found
};
export const schemaValidateIssueToLiveValidationIssue = (
input: SchemaValidateIssue[],
operation: Operation,
ctx: SchemaValidateContext,
output: LiveValidationIssue[],
_operationContext: OperationContext,
_isArmCall?: boolean,
_logging?: LoggingFn,
_bodyPayload?: any
) => {
for (const i of input) {
const issue = i as Writable<LiveValidationIssue>;
issue.resourceIds = [];
issue.documentationUrl = "";
const source = issue.source as Writable<SourceLocation>;
if (!source.url) {
source.url = operation._path._spec._filePath;
}
let skipIssue = false;
issue.pathsInPayload = issue.jsonPathsInPayload.map((path, idx) => {
if (issue.code === "MISSING_RESOURCE_ID") {
// ignore this error for sub level resources
if (path.includes("properties")) {
skipIssue = true;
return "";
}
}
const isMissingRequiredProperty = issue.code === "OBJECT_MISSING_REQUIRED_PROPERTY";
const isBodyIssue = path.startsWith(".body");
if (isBodyIssue && (path.length > 5 || !isMissingRequiredProperty)) {
path = "$" + path.substr(5);
issue.jsonPathsInPayload[idx] = path;
const resolvedJsonPath = jsonPathToPointer(path);
if (ctx.isResponse && isBodyIssue) {
const resourceId = findResourceId(_bodyPayload, path);
if (resourceId) {
issue.resourceIds!.push(resourceId);
}
}
return resolvedJsonPath;
}
if (isMissingRequiredProperty) {
if (ctx.isResponse) {
if (isBodyIssue) {
issue.code = "INVALID_RESPONSE_BODY";
// If a long running operation with code 201 or 202 then it could has empty body
if (
operation["x-ms-long-running-operation"] &&
(ctx.statusCode === "201" || ctx.statusCode === "202")
) {
skipIssue = true;
}
} else if (path.startsWith(".headers")) {
issue.code = "INVALID_RESPONSE_HEADER";
}
} else {
// In request
issue.code = "MISSING_REQUIRED_PARAMETER";
}
const meta = getOavErrorMeta(issue.code, { missingProperty: issue.params[0] });
issue.severity = meta.severity;
issue.message = meta.message;
}
const resolvedJsonPath = jsonPathToPointer(path);
if (ctx.isResponse && isBodyIssue) {
const resourceId = findResourceId(_bodyPayload, path);
if (resourceId) {
issue.resourceIds!.push(resourceId);
}
}
return resolvedJsonPath;
});
if (!skipIssue) {
output.push(issue);
}
}
};
const validateLroOperation = (
operation: Operation,
statusCode: string,
headers: StringMap<string>,
result: LiveValidationIssue[]
) => {
if (operation["x-ms-long-running-operation"] === true) {
if (operation._method === "post") {
if (statusCode === "202" || statusCode === "201") {
validateLroHeader(operation, statusCode, headers, result);
} else if (statusCode !== "200" && statusCode !== "204") {
result.push(issueFromErrorCode("LRO_RESPONSE_CODE", { statusCode }, operation.responses));
}
} else if (operation._method === "patch") {
if (statusCode === "202" || statusCode === "201") {
validateLroHeader(operation, statusCode, headers, result);
} else if (statusCode !== "200") {
result.push(issueFromErrorCode("LRO_RESPONSE_CODE", { statusCode }, operation.responses));
}
} else if (operation._method === "delete") {
if (statusCode === "202") {
validateLroHeader(operation, statusCode, headers, result);
} else if (statusCode !== "200" && statusCode !== "204") {
result.push(issueFromErrorCode("LRO_RESPONSE_CODE", { statusCode }, operation.responses));
}
} else if (operation._method === "put") {
if (statusCode === "202" || statusCode === "201") {
validateLroHeader(operation, statusCode, headers, result);
} else if (statusCode !== "200") {
result.push(issueFromErrorCode("LRO_RESPONSE_CODE", { statusCode }, operation.responses));
}
}
}
};
const validateLroHeader = (
operation: Operation,
statusCode: string,
headers: StringMap<string>,
result: LiveValidationIssue[]
) => {
if (statusCode === "201") {
// Ignore LRO header check cause RPC says azure-AsyncOperation is optional if using 201/200+ provisioningState
return;
}
if (
(headers.location === undefined || headers.location === "") &&
(headers["azure-AsyncOperation"] === undefined || headers["azure-AsyncOperation"] === "") &&
(headers["azure-asyncoperation"] === undefined || headers["azure-asyncoperation"] === "")
) {
result.push(
issueFromErrorCode(
"LRO_RESPONSE_HEADER",
{
header: "location or azure-AsyncOperation",
},
operation.responses
)
);
}
};
export const issueFromErrorCode = (
code: TrafficValidationErrorCode,
param: any,
relatedSchema?: {}
): LiveValidationIssue => {
const meta = getOavErrorMeta(code, param);
return {
code,
severity: meta.severity,
message: meta.message,
jsonPathsInPayload: [],
pathsInPayload: [],
schemaPath: "",
source: sourceMapInfoToSourceLocation(getInfo(relatedSchema)),
documentationUrl: "",
};
};