oav/lib/swaggerValidator/modelValidator.ts

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 {}