1039 строки
36 KiB
TypeScript
1039 строки
36 KiB
TypeScript
import { ParsedUrlQuery } from "querystring";
|
|
import { inject, injectable } from "inversify";
|
|
import { FilePosition, getInfo, ParseError, StringMap } from "@azure-tools/openapi-tools-common";
|
|
import * as openapiToolsCommon from "@azure-tools/openapi-tools-common";
|
|
import jsonPointer from "json-pointer";
|
|
import { parseInt } from "lodash";
|
|
import * as C from "../util/constants";
|
|
import { inversifyGetContainer, inversifyGetInstance, TYPES } from "../inversifyUtils";
|
|
import { LiveValidatorLoader } from "../liveValidation/liveValidatorLoader";
|
|
import { JsonLoader, JsonLoaderRefError } from "../swagger/jsonLoader";
|
|
import { isSuppressedInPath, SuppressionLoader } from "../swagger/suppressionLoader";
|
|
import { SwaggerLoaderOption } from "../swagger/swaggerLoader";
|
|
import {
|
|
BodyParameter,
|
|
LowerHttpMethods,
|
|
Operation,
|
|
Parameter,
|
|
Path,
|
|
refSelfSymbol,
|
|
SwaggerExample,
|
|
SwaggerSpec,
|
|
} from "../swagger/swaggerTypes";
|
|
import {
|
|
ErrorCodeConstants,
|
|
getOavErrorMeta,
|
|
ModelValidationErrorCode,
|
|
modelValidationErrors,
|
|
} from "../util/errorDefinitions";
|
|
import { traverseSwaggerAsync } from "../transform/traverseSwagger";
|
|
import { xmsPathsTransformer } from "../transform/xmsPathsTransformer";
|
|
import { resolveNestedDefinitionTransformer } from "../transform/resolveNestedDefinitionTransformer";
|
|
import { referenceFieldsTransformer } from "../transform/referenceFieldsTransformer";
|
|
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 { getTransformContext } from "../transform/context";
|
|
import { applyGlobalTransformers, applySpecTransformers } from "../transform/transformer";
|
|
import {
|
|
transformBodyValue,
|
|
transformLiveHeader,
|
|
transformMapValue,
|
|
} from "../liveValidation/operationValidator";
|
|
import { log } from "../util/logging";
|
|
import { getFilePositionFromJsonPath } from "../util/jsonUtils";
|
|
import { checkAndResolveGithubUrl, getProviderFromSpecPath } from "../util/utils";
|
|
import { Severity } from "../util/severity";
|
|
import { ValidationResultSource } from "../util/validationResultSource";
|
|
import { SchemaValidateIssue, SchemaValidator, SchemaValidatorOption } from "./schemaValidator";
|
|
|
|
@injectable()
|
|
export class SwaggerExampleValidator {
|
|
private specPath: string;
|
|
private swagger: SwaggerSpec;
|
|
public errors: SwaggerExampleErrorDetail[] = [];
|
|
|
|
public constructor(
|
|
@inject(TYPES.opts) _opts: ExampleValidationOption,
|
|
private jsonLoader: JsonLoader,
|
|
private suppressionLoader: SuppressionLoader,
|
|
private liveValidatorLoader: LiveValidatorLoader,
|
|
@inject(TYPES.schemaValidator) private schemaValidator: SchemaValidator
|
|
) {}
|
|
|
|
public async validateOperations(operationIds?: string): Promise<void> {
|
|
try {
|
|
if (
|
|
operationIds !== null &&
|
|
operationIds !== undefined &&
|
|
typeof operationIds.valueOf() !== "string"
|
|
) {
|
|
throw new Error(
|
|
`please pass in operationIds parameter correctly. It must be a string separated by comma if containing multiple operationIds.`
|
|
);
|
|
}
|
|
let operationIdArray: string[] = [];
|
|
if (operationIds !== undefined) {
|
|
operationIdArray = operationIds.trim().split(",");
|
|
}
|
|
|
|
// Parse the swagger resolving the ref
|
|
await this.loadSwagger(this.specPath, false);
|
|
if (this.swagger === undefined || this.errors.length > 0) {
|
|
return;
|
|
}
|
|
await this.suppressionLoader.load(this.swagger);
|
|
await this.transformSwagger(this.swagger);
|
|
await this.liveValidatorLoader.buildAjvValidator(this.swagger);
|
|
await traverseSwaggerAsync(this.swagger, {
|
|
onOperation: async (_operation: Operation, _path: Path, _method: LowerHttpMethods) => {
|
|
if (
|
|
operationIdArray.length === 0 ||
|
|
(operationIdArray.length > 0 && operationIdArray.includes(_operation.operationId!))
|
|
) {
|
|
if (!_operation["x-ms-examples"]) {
|
|
const meta = getOavErrorMeta(ErrorCodeConstants.XMS_EXAMPLE_NOTFOUND_ERROR as any, {
|
|
operationId: _operation.operationId,
|
|
});
|
|
this.addErrorsFromErrorCode(_operation.operationId!, undefined, meta, _operation);
|
|
return;
|
|
}
|
|
await this.validateOperation(_operation);
|
|
}
|
|
},
|
|
});
|
|
} catch (e) {
|
|
log.error(`validateOperations - ErrorMessage:${e?.message}.ErrorStack:${e?.stack}`);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
public async initialize(specPath: string): Promise<void> {
|
|
if (
|
|
specPath === null ||
|
|
specPath === undefined ||
|
|
typeof specPath.valueOf() !== "string" ||
|
|
!specPath.trim().length
|
|
) {
|
|
throw new Error(
|
|
"specPath is a required parameter of type string and it cannot be an empty string."
|
|
);
|
|
}
|
|
this.specPath = checkAndResolveGithubUrl(specPath);
|
|
}
|
|
|
|
private async validateOperation(operation: Operation): Promise<void> {
|
|
// load example content
|
|
let exampleContent;
|
|
let exampleFileUrl;
|
|
for (const scenario of Object.keys(operation["x-ms-examples"]!)) {
|
|
const mockUrl = operation["x-ms-examples"]![scenario].$ref!;
|
|
if (mockUrl === undefined) {
|
|
throw new Error(
|
|
`$ref is undefined in x-ms-examples defintion for operation ${operation.operationId}.`
|
|
);
|
|
}
|
|
exampleContent = this.jsonLoader.resolveMockedFile(mockUrl) as SwaggerExample;
|
|
exampleFileUrl = this.jsonLoader.getRealPath(mockUrl);
|
|
this.validateRequest(operation, exampleContent, exampleFileUrl);
|
|
this.validateResponse(operation, exampleContent, exampleFileUrl);
|
|
} // end of scenario for loop
|
|
}
|
|
|
|
private validateResponse(operation: Operation, exampleContent: any, exampleFileUrl: string) {
|
|
const exampleResponsesObj = exampleContent.responses;
|
|
if (
|
|
exampleResponsesObj === null ||
|
|
exampleResponsesObj === undefined ||
|
|
typeof exampleResponsesObj !== "object"
|
|
) {
|
|
throw new Error(
|
|
`For operation:${operation.operationId}, responses in example file ${exampleFileUrl} cannot be null or undefined and must be of type 'object'.`
|
|
);
|
|
}
|
|
|
|
const definedResponsesInSwagger: string[] = [];
|
|
for (const statusCode of openapiToolsCommon.keys(operation.responses)) {
|
|
definedResponsesInSwagger.push(statusCode);
|
|
}
|
|
// loop for each response code
|
|
for (const exampleResponseStatusCode of openapiToolsCommon.keys(exampleResponsesObj)) {
|
|
const responseDefinition = operation.responses;
|
|
|
|
const responseSchema = responseDefinition[exampleResponseStatusCode];
|
|
if (!responseSchema) {
|
|
const meta = getOavErrorMeta(ErrorCodeConstants.RESPONSE_STATUS_CODE_NOT_IN_SPEC as any, {
|
|
exampleResponseStatusCode,
|
|
operationId: operation.operationId,
|
|
});
|
|
this.addErrorsFromErrorCode(
|
|
operation.operationId!,
|
|
exampleFileUrl,
|
|
meta,
|
|
responseDefinition,
|
|
undefined,
|
|
exampleResponsesObj,
|
|
`$response.${exampleResponseStatusCode}`,
|
|
ValidationResultSource.RESPONSE
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// remove response entry after validation
|
|
const responseIndex = definedResponsesInSwagger.indexOf(exampleResponseStatusCode);
|
|
if (responseIndex > -1) {
|
|
definedResponsesInSwagger.splice(responseIndex, 1);
|
|
}
|
|
|
|
const exampleResponseHeaders = exampleResponsesObj[exampleResponseStatusCode].headers || {};
|
|
const exampleResponseBody = exampleResponsesObj[exampleResponseStatusCode].body;
|
|
|
|
// Fail when example provides the response body but the swagger spec doesn't define the schema for the response.
|
|
if (exampleResponseBody !== undefined) {
|
|
if (!responseSchema.schema) {
|
|
// having response body but doesn't have schema defined in response swagger definition
|
|
const meta = getOavErrorMeta(ErrorCodeConstants.RESPONSE_SCHEMA_NOT_IN_SPEC as any, {
|
|
exampleResponseStatusCode,
|
|
operationId: operation.operationId,
|
|
});
|
|
this.addErrorsFromErrorCode(
|
|
operation.operationId!,
|
|
exampleFileUrl,
|
|
meta,
|
|
responseDefinition,
|
|
undefined,
|
|
exampleResponsesObj,
|
|
`$response.${exampleResponseStatusCode}/body`,
|
|
ValidationResultSource.RESPONSE
|
|
);
|
|
continue;
|
|
} else if (responseSchema.schema.type !== typeof exampleResponseBody) {
|
|
const validBody = this.validateBodyType(
|
|
operation.operationId!,
|
|
responseSchema,
|
|
exampleResponseBody,
|
|
exampleFileUrl,
|
|
responseDefinition,
|
|
exampleResponsesObj,
|
|
exampleResponseStatusCode
|
|
);
|
|
if (!validBody) {
|
|
continue;
|
|
}
|
|
}
|
|
} else if (exampleResponseBody === undefined && responseSchema.schema) {
|
|
// Fail when example doesn't provide the response body but the swagger spec define the schema for the response.
|
|
const meta = getOavErrorMeta(ErrorCodeConstants.RESPONSE_BODY_NOT_IN_EXAMPLE as any, {
|
|
exampleResponseStatusCode,
|
|
operationId: operation.operationId,
|
|
});
|
|
this.addErrorsFromErrorCode(
|
|
operation.operationId!,
|
|
exampleFileUrl,
|
|
meta,
|
|
responseDefinition,
|
|
undefined,
|
|
exampleResponsesObj,
|
|
`$response.${exampleResponseStatusCode}`,
|
|
ValidationResultSource.RESPONSE
|
|
);
|
|
continue;
|
|
}
|
|
// validate headers
|
|
const headers = transformLiveHeader(exampleResponseHeaders, responseSchema);
|
|
this.validateLroOperation(
|
|
exampleFileUrl,
|
|
operation,
|
|
exampleResponseStatusCode,
|
|
headers,
|
|
exampleResponseHeaders
|
|
);
|
|
if (responseSchema.schema !== undefined) {
|
|
if (headers["content-type"] !== undefined) {
|
|
this.validateContentType(
|
|
operation.operationId!,
|
|
exampleFileUrl,
|
|
operation.produces!,
|
|
headers,
|
|
false,
|
|
exampleResponseStatusCode,
|
|
operation,
|
|
exampleResponseHeaders
|
|
);
|
|
}
|
|
const validate = responseSchema._validate!;
|
|
const ctx = {
|
|
isResponse: true,
|
|
statusCode: exampleResponseStatusCode,
|
|
httpMethod: operation._method,
|
|
};
|
|
const ajvValidatorErrors = validate(ctx, {
|
|
headers,
|
|
body: exampleResponseBody,
|
|
});
|
|
this.schemaIssuesToModelValidationIssues(
|
|
operation.operationId!,
|
|
false,
|
|
ajvValidatorErrors,
|
|
exampleFileUrl,
|
|
exampleContent,
|
|
undefined,
|
|
exampleResponseStatusCode,
|
|
operation._method
|
|
);
|
|
}
|
|
}
|
|
|
|
// report missing response in example
|
|
for (const statusCodeInSwagger of definedResponsesInSwagger) {
|
|
if (statusCodeInSwagger !== "default") {
|
|
const meta = getOavErrorMeta(
|
|
ErrorCodeConstants.RESPONSE_STATUS_CODE_NOT_IN_EXAMPLE as any,
|
|
{ statusCodeInSwagger, operationId: operation.operationId }
|
|
);
|
|
this.addErrorsFromErrorCode(
|
|
operation.operationId!,
|
|
exampleFileUrl,
|
|
meta,
|
|
operation.responses[statusCodeInSwagger],
|
|
undefined,
|
|
exampleResponsesObj,
|
|
`response`,
|
|
ValidationResultSource.RESPONSE
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private validateRequest(operation: Operation, exampleContent: any, exampleFileUrl: string) {
|
|
const parameterizedHostDef = operation._path._spec["x-ms-parameterized-host"];
|
|
const useSchemePrefix = parameterizedHostDef
|
|
? parameterizedHostDef.useSchemePrefix === undefined
|
|
? true
|
|
: parameterizedHostDef.useSchemePrefix
|
|
: null;
|
|
const pathTemplate = operation._path._pathTemplate;
|
|
const parameters = operation.parameters;
|
|
const publicParameters = operation._path.parameters;
|
|
const mergedParameters = [...(parameters ?? []), ...(publicParameters ?? [])];
|
|
const pathParameters: { [key: string]: string } = {};
|
|
let bodyParameter: any = {};
|
|
const queryParameters: ParsedUrlQuery = {};
|
|
const formData: { [key: string]: string } = {};
|
|
const exampleRequestHeaders: { [propertyName: string]: string } = {};
|
|
if (mergedParameters === undefined) {
|
|
return;
|
|
}
|
|
for (const p of mergedParameters) {
|
|
const parameter = this.jsonLoader.resolveRefObj(p);
|
|
let parameterValue = exampleContent?.parameters[parameter.name];
|
|
if (!parameterValue) {
|
|
if (parameter.required) {
|
|
const meta = getOavErrorMeta(
|
|
ErrorCodeConstants.REQUIRED_PARAMETER_EXAMPLE_NOT_FOUND as any,
|
|
{ operationId: operation.operationId, name: parameter.name }
|
|
);
|
|
this.addErrorsFromErrorCode(
|
|
operation.operationId!,
|
|
exampleFileUrl,
|
|
meta,
|
|
operation,
|
|
undefined,
|
|
p
|
|
);
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
const location = parameter.in;
|
|
if (location === "path") {
|
|
if (location === "path" && parameterValue && typeof parameterValue === "string") {
|
|
// "/{scope}/scopes/resourceGroups/{resourceGroupName}" In the aforementioned path
|
|
// template, we will search for the path parameter based on it's name
|
|
// for example: "scope". Find it's index in the string and move backwards by 2 positions.
|
|
// If the character at that position is a forward slash "/" and
|
|
// the value for the parameter starts with a forward slash "/" then we have found the case
|
|
// where there will be duplicate forward slashes in the url.
|
|
if (
|
|
pathTemplate.charAt(pathTemplate.indexOf(`${parameter.name}`) - 2) === "/" &&
|
|
parameterValue.startsWith("/")
|
|
) {
|
|
const meta = getOavErrorMeta(ErrorCodeConstants.DOUBLE_FORWARD_SLASHES_IN_URL as any, {
|
|
operationId: operation.operationId,
|
|
parameterName: parameter.name,
|
|
parameterValue,
|
|
pathTemplate,
|
|
});
|
|
this.addErrorsFromErrorCode(
|
|
operation.operationId!,
|
|
exampleFileUrl,
|
|
meta,
|
|
operation,
|
|
undefined,
|
|
p
|
|
);
|
|
break;
|
|
}
|
|
// replacing characters that may cause validator failed with empty string because this messes up Sways regex
|
|
// validation of path segment.
|
|
parameterValue = parameterValue.replace(/\//gi, "");
|
|
|
|
// replacing scheme that may cause validator failed when x-ms-parameterized-host enbaled & useSchemePrefix enabled
|
|
// because if useSchemePrefix enabled ,the parameter value in x-ms-parameterized-host should not has the scheme (http://|https://)
|
|
if (useSchemePrefix) {
|
|
parameterValue = (parameterValue as string).replace(/^https{0,1}:/gi, "");
|
|
}
|
|
}
|
|
// todo skip url encoding
|
|
pathParameters[parameter.name] = parameterValue;
|
|
} else if (location === "query") {
|
|
// validate the api version value
|
|
if (parameter.name === "api-version" && parameterValue !== this.swagger.info.version) {
|
|
const meta = getOavErrorMeta("INVALID_REQUEST_PARAMETER", {
|
|
parameterName: "api-version",
|
|
apiVersion: parameterValue,
|
|
});
|
|
this.addErrorsFromErrorCode(
|
|
operation.operationId!,
|
|
exampleFileUrl,
|
|
meta,
|
|
operation,
|
|
undefined,
|
|
exampleContent?.parameters,
|
|
`$parameters["api-version"]`
|
|
);
|
|
continue;
|
|
}
|
|
queryParameters[parameter.name] = parameterValue;
|
|
} else if (location === "body") {
|
|
if ((parameter as BodyParameter).schema?.format === "file") {
|
|
continue;
|
|
}
|
|
bodyParameter = parameterValue;
|
|
} else if (location === "header") {
|
|
exampleRequestHeaders[parameter.name] = parameterValue;
|
|
} else if (location === "formData") {
|
|
formData[parameter.name] = parameterValue;
|
|
}
|
|
} // end of mergedParameters for loop
|
|
transformMapValue(queryParameters, operation._queryTransform);
|
|
transformMapValue(pathParameters, operation._pathTransform);
|
|
const validate = operation._validate!;
|
|
const ctx = { isResponse: false };
|
|
const headers = transformLiveHeader(exampleRequestHeaders, operation);
|
|
const ajvValidatorErrors = validate(ctx, {
|
|
path: pathParameters,
|
|
body: transformBodyValue(bodyParameter, operation),
|
|
query: queryParameters,
|
|
headers,
|
|
formData,
|
|
});
|
|
this.schemaIssuesToModelValidationIssues(
|
|
operation.operationId!,
|
|
true,
|
|
ajvValidatorErrors,
|
|
exampleFileUrl,
|
|
exampleContent,
|
|
mergedParameters
|
|
);
|
|
}
|
|
private async loadSwagger(swaggerFilePath: string, skipResolveRef: boolean) {
|
|
try {
|
|
this.swagger = (await this.jsonLoader.load(
|
|
swaggerFilePath,
|
|
skipResolveRef
|
|
)) as unknown as SwaggerSpec;
|
|
this.swagger._filePath = swaggerFilePath;
|
|
} catch (e) {
|
|
if (typeof e.kind === "string") {
|
|
const ex = e as ParseError;
|
|
const errInfo = getOavErrorMeta("JSON_PARSING_ERROR", { details: ex.code });
|
|
this.errors.push({
|
|
code: errInfo.code as any,
|
|
message: errInfo.message,
|
|
schemaPosition: ex.position,
|
|
schemaUrl: ex.url,
|
|
});
|
|
} else if (e instanceof JsonLoaderRefError) {
|
|
const errInfo = getOavErrorMeta("UNRESOLVABLE_REFERENCE", { ref: e.ref });
|
|
this.errors.push({
|
|
code: errInfo.code as any,
|
|
message: errInfo.message,
|
|
schemaPosition: e.position,
|
|
schemaUrl: e.url,
|
|
});
|
|
} else {
|
|
throw e;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
private async transformSwagger(spec: SwaggerSpec) {
|
|
const transformCtx = getTransformContext(this.jsonLoader, this.schemaValidator, [
|
|
xmsPathsTransformer,
|
|
resolveNestedDefinitionTransformer,
|
|
referenceFieldsTransformer,
|
|
pathRegexTransformer,
|
|
|
|
discriminatorTransformer,
|
|
allOfTransformer,
|
|
noAdditionalPropertiesTransformer,
|
|
nullableTransformer,
|
|
pureObjectTransformer,
|
|
]);
|
|
applySpecTransformers(spec, transformCtx);
|
|
applyGlobalTransformers(transformCtx);
|
|
}
|
|
|
|
private addErrorsFromErrorCode(
|
|
operationId: string,
|
|
exampleUrl: string | undefined,
|
|
meta: ReturnType<typeof getOavErrorMeta>,
|
|
schemaObj?: any,
|
|
schemaJsonPath?: string,
|
|
exampleObj?: any,
|
|
exampleJsonPath?: string,
|
|
source?: ValidationResultSource
|
|
) {
|
|
if (
|
|
isSuppressedInPath(schemaObj, meta.id!, meta.message) ||
|
|
isSuppressedInPath(schemaObj, meta.code, meta.message)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const schemaInfo = getInfo(schemaObj);
|
|
const exampleInfo = getInfo(exampleObj);
|
|
this.errors.push({
|
|
code: meta.code as ModelValidationErrorCode,
|
|
message: meta.message,
|
|
schemaUrl: this.specPath,
|
|
exampleUrl,
|
|
schemaPosition: schemaInfo?.position,
|
|
schemaJsonPath: schemaJsonPath ?? schemaObj?.[refSelfSymbol],
|
|
examplePosition: exampleInfo?.position,
|
|
exampleJsonPath: exampleJsonPath,
|
|
severity: meta.severity,
|
|
source: source ?? ValidationResultSource.GLOBAL,
|
|
operationId,
|
|
});
|
|
}
|
|
|
|
private schemaIssuesToModelValidationIssues(
|
|
operationId: string,
|
|
isRequest: boolean,
|
|
errors: SchemaValidateIssue[],
|
|
exampleUrl: string,
|
|
exampleContent: any,
|
|
parameters?: Parameter[],
|
|
statusCode?: string,
|
|
httpMethod?: string
|
|
) {
|
|
for (const err of errors) {
|
|
// ignore below schema errors
|
|
if (
|
|
(err.code === "NOT_PASSED" &&
|
|
(err.message.includes('should match "else" schema') ||
|
|
err.message.includes('should match "then" schema'))) ||
|
|
((err.code as any) === "SECRET_PROPERTY" && httpMethod === "post")
|
|
) {
|
|
continue;
|
|
}
|
|
let isSuppressed = false;
|
|
let schemaPosition;
|
|
let examplePosition;
|
|
let externalSwagger;
|
|
let schemaUrl = this.specPath;
|
|
const exampleJsonPaths: string[] = [];
|
|
if (isRequest) {
|
|
for (const p of parameters!) {
|
|
const parameter = this.jsonLoader.resolveRefObj(p);
|
|
if (
|
|
err.schemaPath.indexOf(parameter.name) > 0 ||
|
|
(err.jsonPathsInPayload.length > 0 &&
|
|
(err.jsonPathsInPayload[0].includes(parameter.name) ||
|
|
(err.jsonPathsInPayload[0].includes(".body") && parameter.in === "body")))
|
|
) {
|
|
schemaPosition = err.source.position;
|
|
if (parameter.in !== "body") {
|
|
const index = err.schemaPath.indexOf(parameter.name);
|
|
err.schemaPath = index > 0 ? err.schemaPath.substr(index) : err.schemaPath;
|
|
}
|
|
if (isSuppressedInPath(parameter, err.code, err.message)) {
|
|
isSuppressed = true;
|
|
}
|
|
exampleJsonPaths.push(`$parameters.${parameter.name}`);
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// response error case
|
|
let sourceSwagger = this.swagger;
|
|
if (err.source.url !== this.swagger._filePath) {
|
|
// use external swagger when the error points to external file
|
|
sourceSwagger = this.jsonLoader.getFileContentFromCache(err.source.url) as any;
|
|
schemaUrl = err.source.url;
|
|
}
|
|
|
|
// check x-nullable value when body is null
|
|
if (err.jsonPathsInPayload.length === 1 && err.jsonPathsInPayload[0] === ".body") {
|
|
const idx = err.source.jsonRef?.indexOf("#");
|
|
if (idx !== undefined && idx !== -1) {
|
|
const jsonRef = err.source.jsonRef?.substr(idx + 1);
|
|
const bodySchema = jsonPointer.get(sourceSwagger, jsonRef!);
|
|
if (bodySchema?.[C.xNullable] === true) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
(err.code as any) === "MISSING_RESOURCE_ID" &&
|
|
exampleContent.responses[statusCode!].body &&
|
|
Object.keys(exampleContent.responses[statusCode!].body).length === 0
|
|
) {
|
|
// ignore this error when whole body of response is empty
|
|
continue;
|
|
}
|
|
|
|
const node = this.getNotSuppressedErrorPath(err);
|
|
if (node === undefined) {
|
|
continue;
|
|
}
|
|
if (externalSwagger === undefined) {
|
|
schemaPosition = getInfo(node)?.position;
|
|
} else {
|
|
schemaPosition = err.source.position;
|
|
}
|
|
|
|
for (let path of err.jsonPathsInPayload) {
|
|
// If parameter name includes ".", path should use "[]" for better understand.
|
|
for (const parameter of err.params) {
|
|
if (
|
|
typeof parameter === "string" &&
|
|
path.includes(`.${parameter}`) &&
|
|
parameter.includes(".")
|
|
) {
|
|
path = path.substring(0, path.indexOf(`.${parameter}`)) + `['${parameter}']`;
|
|
}
|
|
}
|
|
exampleJsonPaths.push(`$responses.${statusCode}${path}`);
|
|
}
|
|
}
|
|
|
|
if (!isSuppressed) {
|
|
for (const jsonPath of exampleJsonPaths) {
|
|
examplePosition = getFilePositionFromJsonPath(exampleContent, jsonPath);
|
|
this.errors.push({
|
|
code: err.code,
|
|
message: err.message,
|
|
schemaUrl,
|
|
exampleUrl,
|
|
schemaPosition: schemaPosition,
|
|
schemaJsonPath: err.schemaPath,
|
|
examplePosition,
|
|
exampleJsonPath: jsonPath,
|
|
severity: err.severity,
|
|
source: isRequest ? ValidationResultSource.REQUEST : ValidationResultSource.RESPONSE,
|
|
operationId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private getNotSuppressedErrorPath = (err: SchemaValidateIssue): any => {
|
|
let node;
|
|
let jsonRef;
|
|
if (err.source.jsonRef === undefined) {
|
|
return undefined;
|
|
}
|
|
const idx = err.source.jsonRef.indexOf("#");
|
|
jsonRef = err.source.jsonRef.substr(idx + 1);
|
|
/*eslint no-constant-condition: ["error", { "checkLoops": false }]*/
|
|
while (true) {
|
|
try {
|
|
if (err.source.url === this.swagger._filePath) {
|
|
node = jsonPointer.get(this.swagger, jsonRef);
|
|
} else {
|
|
const externalSwagger = this.jsonLoader.getFileContentFromCache(err.source.url);
|
|
node = jsonPointer.get(externalSwagger as openapiToolsCommon.JsonObject, jsonRef);
|
|
}
|
|
const isSuppressed = isSuppressedInPath(node, err.code, err.message);
|
|
if (isSuppressed) {
|
|
return undefined;
|
|
} else {
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
let isContinue = false;
|
|
// the jsonRef 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 = jsonRef.lastIndexOf(token);
|
|
if (index > 0) {
|
|
jsonRef = jsonRef.substring(0, index);
|
|
if (jsonRef.endsWith(".") || jsonRef.endsWith("/")) {
|
|
jsonRef = jsonRef.substring(0, jsonRef.length - 1);
|
|
}
|
|
isContinue = true;
|
|
}
|
|
}
|
|
// if it's not the case of containing unexisted path, then throw this error
|
|
if (isContinue === false) {
|
|
log.error(`Exception in filtering not suppressed error path:${jsonRef}.`);
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
return node;
|
|
};
|
|
private issueFromErrorCode = (
|
|
operationId: string,
|
|
examplePath: string,
|
|
code: ModelValidationErrorCode,
|
|
param: any,
|
|
schemaObj?: any,
|
|
schemaJsonPath?: string,
|
|
exampleObj?: any,
|
|
exampleJsonPath?: string,
|
|
source?: ValidationResultSource
|
|
): SwaggerExampleErrorDetail => {
|
|
const meta = getOavErrorMeta(code, param);
|
|
const schemaInfo = schemaObj ? getInfo(schemaObj) : schemaObj;
|
|
const exampleInfo = exampleObj ? getInfo(exampleObj) : exampleObj;
|
|
return {
|
|
code,
|
|
message: meta.message,
|
|
schemaUrl: this.specPath,
|
|
exampleUrl: examplePath,
|
|
schemaPosition: schemaInfo?.position,
|
|
schemaJsonPath: schemaJsonPath,
|
|
examplePosition: exampleInfo?.position,
|
|
exampleJsonPath: exampleJsonPath,
|
|
severity: meta.severity,
|
|
source: source ?? ValidationResultSource.GLOBAL,
|
|
operationId,
|
|
};
|
|
};
|
|
|
|
private validateBodyType = (
|
|
operationId: string,
|
|
responseSchema: any,
|
|
body: any,
|
|
exampleFileUrl: string,
|
|
responseDefinition: any,
|
|
exampleResponsesObj: any,
|
|
exampleResponseStatusCode: string
|
|
): boolean => {
|
|
let expectedType = responseSchema.schema.type;
|
|
const actualType = typeof body;
|
|
let bodySchema: any = {};
|
|
|
|
if (responseSchema.schema.format === "file" || expectedType === "file") {
|
|
// ignore validation in case of file type
|
|
return true;
|
|
} else if (expectedType === undefined) {
|
|
// in case of body providing primitive type
|
|
bodySchema = this.jsonLoader.resolveRefObj(responseSchema.schema);
|
|
// set excpectedType as 'object' if the schema has properties key or additionalProperties key
|
|
if (
|
|
bodySchema.type === undefined &&
|
|
(Object.keys(bodySchema).includes("properties") ||
|
|
Object.keys(bodySchema).includes("additionalProperties"))
|
|
) {
|
|
expectedType = "object";
|
|
} else {
|
|
expectedType = bodySchema.type;
|
|
}
|
|
}
|
|
|
|
let invalidTypeError: boolean = false;
|
|
if (
|
|
(actualType === "number" && expectedType === "integer") ||
|
|
(actualType === "object" && expectedType === "array") ||
|
|
(Object.keys(bodySchema)?.length === 0 && Object.keys(body)?.length === 0)
|
|
) {
|
|
invalidTypeError = false;
|
|
} else if (expectedType !== actualType) {
|
|
invalidTypeError = true;
|
|
}
|
|
|
|
if (invalidTypeError) {
|
|
const meta = getOavErrorMeta(ErrorCodeConstants.INVALID_TYPE as any, {
|
|
type: expectedType,
|
|
data: actualType,
|
|
});
|
|
this.addErrorsFromErrorCode(
|
|
operationId,
|
|
exampleFileUrl,
|
|
meta,
|
|
responseDefinition,
|
|
undefined,
|
|
exampleResponsesObj,
|
|
`$response.${exampleResponseStatusCode}/body`,
|
|
ValidationResultSource.RESPONSE
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
private validateContentType = (
|
|
operationId: string,
|
|
examplePath: string,
|
|
allowedContentTypes: string[],
|
|
headers: StringMap<string>,
|
|
isRequest: boolean,
|
|
statusCode: string,
|
|
schemaObj?: any,
|
|
exampleObj?: any
|
|
) => {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
this.errors.push(
|
|
this.issueFromErrorCode(
|
|
operationId,
|
|
examplePath,
|
|
"INVALID_CONTENT_TYPE",
|
|
{
|
|
contentType,
|
|
supported: allowedContentTypes.join(", "),
|
|
},
|
|
schemaObj,
|
|
undefined,
|
|
exampleObj,
|
|
`responses/${statusCode}/headers`,
|
|
ValidationResultSource.RESPONSE
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
private validateLroOperation = (
|
|
examplePath: string,
|
|
operation: Operation,
|
|
statusCode: string,
|
|
headers: StringMap<string>,
|
|
exampleObj?: any
|
|
) => {
|
|
if (operation["x-ms-long-running-operation"] === true && parseInt(statusCode, 10) < 300) {
|
|
if (operation._method === "post") {
|
|
if (statusCode === "202" || statusCode === "201") {
|
|
this.validateLroHeader(examplePath, operation, statusCode, headers);
|
|
} else if (statusCode !== "200" && statusCode !== "204") {
|
|
this.errors.push(
|
|
this.issueFromErrorCode(
|
|
operation.operationId!,
|
|
examplePath,
|
|
"LRO_RESPONSE_CODE",
|
|
{ statusCode },
|
|
operation.responses,
|
|
undefined,
|
|
exampleObj,
|
|
`responses/${statusCode}`,
|
|
ValidationResultSource.RESPONSE
|
|
)
|
|
);
|
|
}
|
|
} else if (operation._method === "patch") {
|
|
if (statusCode === "202" || statusCode === "201") {
|
|
this.validateLroHeader(examplePath, operation, statusCode, headers);
|
|
} else if (statusCode !== "200") {
|
|
this.errors.push(
|
|
this.issueFromErrorCode(
|
|
operation.operationId!,
|
|
examplePath,
|
|
"LRO_RESPONSE_CODE",
|
|
{ statusCode },
|
|
operation.responses,
|
|
undefined,
|
|
exampleObj,
|
|
`responses/${statusCode}`,
|
|
ValidationResultSource.RESPONSE
|
|
)
|
|
);
|
|
}
|
|
} else if (operation._method === "delete") {
|
|
if (statusCode === "202") {
|
|
this.validateLroHeader(examplePath, operation, statusCode, headers);
|
|
} else if (statusCode !== "200" && statusCode !== "204") {
|
|
this.errors.push(
|
|
this.issueFromErrorCode(
|
|
operation.operationId!,
|
|
examplePath,
|
|
"LRO_RESPONSE_CODE",
|
|
{ statusCode },
|
|
operation.responses,
|
|
undefined,
|
|
exampleObj,
|
|
`responses/${statusCode}`,
|
|
ValidationResultSource.RESPONSE
|
|
)
|
|
);
|
|
}
|
|
} else if (operation._method === "put") {
|
|
if (statusCode !== "200" && statusCode !== "201" && statusCode !== "202") {
|
|
this.errors.push(
|
|
this.issueFromErrorCode(
|
|
operation.operationId!,
|
|
examplePath,
|
|
"LRO_RESPONSE_CODE",
|
|
{ statusCode },
|
|
operation.responses,
|
|
undefined,
|
|
exampleObj,
|
|
`responses/${statusCode}`,
|
|
ValidationResultSource.RESPONSE
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
private validateLroHeader = (
|
|
examplePath: string,
|
|
operation: Operation,
|
|
statusCode: string,
|
|
headers: StringMap<string>,
|
|
exampleObj?: any
|
|
) => {
|
|
if (statusCode === "201") {
|
|
// Ignore LRO header check cause RPC says azure-AsyncOperation is optional if using 201/200+ provisioningState
|
|
return;
|
|
}
|
|
|
|
const provider = getProviderFromSpecPath(this.specPath);
|
|
const providerType = provider?.type;
|
|
if (providerType !== undefined && this.isMissingHeader(providerType, headers)) {
|
|
this.errors.push(
|
|
this.issueFromErrorCode(
|
|
operation.operationId!,
|
|
examplePath,
|
|
"LRO_RESPONSE_HEADER",
|
|
{
|
|
header:
|
|
providerType === "resource-manager"
|
|
? "location or azure-AsyncOperation"
|
|
: "Operation-Id or Operation-Location or azure-AsyncOperation", // providerType === "data-plane"
|
|
},
|
|
operation.responses,
|
|
undefined,
|
|
exampleObj,
|
|
`responses/${statusCode}/headers`,
|
|
ValidationResultSource.RESPONSE
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
private isMissingHeader = (
|
|
providerType: "resource-manager" | "data-plane",
|
|
header: StringMap<string>
|
|
) => {
|
|
const headerConfig = {
|
|
"resource-manager": {
|
|
checkFirst: ["Location", "location", "azure-AsyncOperation", "azure-asyncoperation"],
|
|
checkSecond: [],
|
|
},
|
|
"data-plane": {
|
|
checkFirst: ["Operation-Id", "operation-id", "Operation-Location", "operation-location"],
|
|
checkSecond: ["azure-AsyncOperation", "azure-asyncoperation"],
|
|
},
|
|
};
|
|
|
|
const config = headerConfig[providerType];
|
|
for (const checkFirst of config.checkFirst) {
|
|
const value = header[checkFirst];
|
|
if (typeof value === "string" && value !== "") {
|
|
return false;
|
|
}
|
|
}
|
|
for (const checkSecond of config.checkSecond) {
|
|
const value = header[checkSecond];
|
|
if (typeof value === "string" && value !== "") {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
}
|
|
|
|
// load all error codes of model validation errors to as suppression option
|
|
const loadSuppression = [];
|
|
for (const errorCode of Object.keys(modelValidationErrors)) {
|
|
const meta = modelValidationErrors[errorCode as ModelValidationErrorCode];
|
|
if ("id" in meta) {
|
|
loadSuppression.push(meta.id);
|
|
}
|
|
loadSuppression.push(errorCode);
|
|
}
|
|
|
|
// Set 'isArmCall flag to true so that the special ARM rules can be applied to examples validation too'
|
|
const defaultOpts: ExampleValidationOption = {
|
|
eraseDescription: false,
|
|
eraseXmsExamples: false,
|
|
useJsonParser: true,
|
|
loadSuppression,
|
|
isArmCall: true,
|
|
};
|
|
|
|
// Compatible wrapper for old ModelValidator
|
|
export class NewModelValidator {
|
|
public validator: SwaggerExampleValidator;
|
|
public result: SwaggerExampleErrorDetail[] = [];
|
|
public constructor(public specPath: string) {
|
|
const container = inversifyGetContainer();
|
|
this.validator = inversifyGetInstance(SwaggerExampleValidator, {
|
|
...defaultOpts,
|
|
container,
|
|
});
|
|
this.result = this.validator.errors;
|
|
}
|
|
|
|
public async initialize() {
|
|
await this.validator.initialize(this.specPath);
|
|
// API compatible
|
|
return null as any;
|
|
}
|
|
|
|
public async validateOperations(operationIds?: string) {
|
|
await this.validator.validateOperations(operationIds);
|
|
}
|
|
}
|
|
|
|
export interface SwaggerExampleErrorDetail {
|
|
inner?: any; // Compatible with old NodeError. Always undefined.
|
|
message: string;
|
|
code: ModelValidationErrorCode;
|
|
schemaPosition?: FilePosition;
|
|
schemaUrl?: string;
|
|
schemaJsonPath?: string;
|
|
examplePosition?: FilePosition;
|
|
exampleUrl?: string;
|
|
exampleJsonPath?: string;
|
|
severity?: Severity;
|
|
source?: ValidationResultSource;
|
|
operationId?: string;
|
|
}
|
|
|
|
export interface ExampleValidationOption extends SwaggerLoaderOption, SchemaValidatorOption {}
|