lib/swaggerValidator/ajv.ts (150 lines of code) (raw):
import { Ajv, CompilationContext } from "ajv";
import { JsonLoader } from "../swagger/jsonLoader";
import { Schema } from "../swagger/swaggerTypes";
import { xmsAzureResource, xmsMutability, xmsSecret } from "../util/constants";
import { ajvEnableDiscriminatorMap } from "./ajvDiscriminatorMap";
export const ajvEnableReadOnlyAndXmsMutability = (ajv: Ajv) => {
ajv.removeKeyword("readOnly");
ajv.addKeyword("readOnly", {
metaSchema: { type: "boolean" },
inline: (
it: CompilationContext,
_keyword: string,
isReadOnly: boolean,
parentSchema: Schema
) => {
if (parentSchema?.[xmsMutability] !== undefined) {
return "1";
}
const data = `data${it.dataLevel || ""}`;
return isReadOnly ? `this.isResponse || ${data} === null || ${data} === undefined` : "1";
},
});
ajv.addKeyword(xmsMutability, {
metaSchema: { type: "array", items: { enum: ["create", "update", "read"] } } as Schema,
inline: (
it: CompilationContext,
_keyword: string,
mutability: Exclude<Schema[typeof xmsMutability], undefined>
) => {
const validInRequest = mutability.includes("create") || mutability.includes("update");
const validInResponse = mutability.includes("read");
if (validInRequest && validInResponse) {
return "1";
}
if (!validInRequest && !validInResponse) {
throw new Error(`Invalid ${xmsMutability} value: ${JSON.stringify(mutability)}`);
}
const data = `data${it.dataLevel || ""}`;
return `${
validInRequest ? "!" : ""
}this.isResponse || ${data} === null || ${data} === undefined`;
},
});
};
export const ajvEnableXmsSecret = (ajv: Ajv) => {
ajv.addKeyword(xmsSecret, {
metaSchema: { type: "boolean" } as Schema,
inline: (it: CompilationContext, _keyword: string, isSecret: boolean) => {
const data = `data${it.dataLevel || ""}`;
return isSecret ? `!this.isResponse || ${data} === null || ${data} === undefined` : "1";
},
});
};
export const ajvEnableXmsAzureResource = (ajv: Ajv) => {
ajv.addKeyword(xmsAzureResource, {
metaSchema: { type: "boolean" } as Schema,
inline: (it: CompilationContext, _keyword: string, isResource: boolean) => {
const data = `data${it.dataLevel || ""}`;
return isResource
? `!(this.isResponse && (this.httpMethod === 'get' || this.httpMethod === 'put')) || (${data}.id !== null && ${data}.id !== undefined)`
: "1";
},
});
};
export const ajvEnableInt32AndInt64Format = (ajv: Ajv) => {
ajv.addFormat("int32", {
type: "number",
validate: (x) => x % 1 === 0 && x >= -2_147_483_648 && x <= 2_147_483_647,
});
// TODO int64 range exceed Number.MAX_SAFE_INTEGER so we will lost precision when JSON.parse
const int64Max = BigInt(2) ** BigInt(63) - BigInt(1);
const int64Min = BigInt(2) ** BigInt(63) * BigInt(-1);
ajv.addFormat("int64", {
type: "number",
validate: (x) => x % 1 === 0 && x >= int64Min && x <= int64Max,
});
};
export const ajvEnableUnixTimeFormat = (ajv: Ajv) => {
ajv.addFormat("unixtime", {
type: "number",
validate: (x) => x % 1 === 0,
});
};
export const ajvAddFormatsDefaultValidation = (
ajv: Ajv,
type: "string" | "number",
formats: string[]
) => {
for (const format of formats) {
ajv.addFormat(format, {
type,
validate: () => true,
});
}
};
export const ajvEnableDateTimeRfc1123Format = (ajv: Ajv) => {
// https://tools.ietf.org/html/rfc822#section-5
ajv.addFormat("date-time-rfc1123", {
type: "string",
validate:
/^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), )?[0-3]\d (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d(?:\d\d)? (?:[0-2]\d:[0-5]\d(?::[0-5]\d)|23:59:60) (?:[A-Z]{1,3})?(?:[+-]\d\d\d\d)?$/,
});
};
export const ajvEnableDurationFormat = (ajv: Ajv) => {
// https://en.wikipedia.org/wiki/ISO_8601#Durations
ajv.addFormat("duration", {
type: "string",
validate:
/^P([0-9]+(?:[,\.][0-9]+)?Y)?([0-9]+(?:[,\.][0-9]+)?M)?([0-9]+(?:[,\.][0-9]+)?D)?(?:T([0-9]+(?:[,\.][0-9]+)?H)?([0-9]+(?:[,\.][0-9]+)?M)?([0-9]+(?:[,\.][0-9]+)?S)?)?$/,
});
};
export const ajvEnableByteFormat = (ajv: Ajv) => {
// https://datatracker.ietf.org/doc/html/rfc4648#section-4
ajv.addFormat("byte", {
type: "string",
validate: (x) => {
const decodedValue = Buffer.from(x, "base64");
const reencodedValue = Buffer.from(decodedValue).toString("base64");
return reencodedValue === x;
},
});
};
// TODO: This could be more advanced, looking at the allowedResources field (see https://github.com/Azure/autorest/tree/main/docs/extensions#schema) and ensuring that only the allowed resources are referenced, but
// TODO: for now a generic ARM ID check is better than nothing.
export const ajvEnableArmIdFormat = (ajv: Ajv) => {
ajv.addFormat("arm-id", {
type: "string",
// Note that this regex isn't perfect but it does an OK job. See https://regex101.com/r/96g3K5/1
validate: new RegExp(
"(^(/subscriptions/([^/]+)(/resourcegroups/([^/]+))?)?/providers/([^/]+)/([^/]+/[^/]+)(/([^/]+/[^/]+))*$|^/subscriptions/([^/]+)(/resourcegroups/([^/]+))?$)",
"i"
),
});
};
// for (const keyword of [
// "name",
// "in",
// "example",
// "parameters",
// "externalDocs",
// "x-nullable",
// "x-ms-enum",
// "x-ms-azure-resource",
// "x-ms-parameter-location",
// "x-ms-client-name",
// "x-ms-external",
// "x-ms-skip-url-encoding",
// "x-ms-client-flatten",
// "x-ms-api-version",
// "x-ms-parameter-grouping",
// "x-ms-discriminator-value",
// "x-ms-client-request-id",
// "x-apim-code-nillable",
// "x-new-pattern",
// "x-previous-pattern",
// "x-comment",
// "x-abstract",
// "allowEmptyValue",
// "collectionFormat",
// ]) {
// ajv.addKeyword(keyword, {});
// }
export const ajvEnableAll = (ajv: Ajv, jsonLoader: JsonLoader) => {
ajvEnableDiscriminatorMap(ajv, jsonLoader);
ajvEnableXmsSecret(ajv);
ajvEnableReadOnlyAndXmsMutability(ajv);
ajvEnableUnixTimeFormat(ajv);
ajvEnableInt32AndInt64Format(ajv);
ajvEnableDurationFormat(ajv);
ajvEnableDateTimeRfc1123Format(ajv);
ajvEnableByteFormat(ajv);
ajvAddFormatsDefaultValidation(ajv, "string", [
"password",
"file",
"base64url",
"",
"binary",
"non-iso-duration",
"char",
]);
ajvAddFormatsDefaultValidation(ajv, "number", ["double", "float", "decimal"]);
};
export const ajvEnableArmRule = (ajv: Ajv) => {
ajvEnableXmsAzureResource(ajv);
};