UNPKG

@compas/code-gen

Version:

Generate various boring parts of your server

1,360 lines (1,235 loc) 33 kB
// @ts-nocheck import { isNil } from "@compas/stdlib"; import { TypeBuilder } from "../builders/index.js"; import { stringifyType } from "../stringify.js"; import { getHashForString } from "../utils.js"; import { formatDocString } from "./comments.js"; import { js } from "./tag/index.js"; import { generateTypeDefinition, getTypeNameForType } from "./types.js"; import { importCreator } from "./utils.js"; /** * @typedef {object} ValidatorContext * @property {import("../generated/common/types").CodeGenContext} context * @property {Map<string, number>} anonymousFunctionMapping * @property {string[]} anonymousFunctions * @property {Map<string, string>} objectSets */ /** * @param {import("../generated/common/types").CodeGenContext} context */ export function generateValidatorFile(context) { /** * @type {ValidatorContext} */ const subContext = { context, anonymousFunctionMapping: new Map(), anonymousFunctions: [], objectSets: new Map(), }; const anonymousValidatorImports = importCreator(); anonymousValidatorImports.destructureImport("isNil", "@compas/stdlib"); for (const group of Object.keys(context.structure)) { const imports = importCreator(); imports.destructureImport("isNil", "@compas/stdlib"); imports.destructureImport("AppError", "@compas/stdlib"); const { sources } = generateValidatorsForGroup( subContext, imports, anonymousValidatorImports, group, ); context.outputFiles.push({ contents: js`${imports.print()} ${sources}`, relativePath: `./${group}/validators${context.extension}`, }); } const result = [ anonymousValidatorImports.print(), ` /** * @typedef {{ * propertyPath: string, * key: string, * info: any, * }} InternalError */ /** * @template T * @typedef {import("@compas/stdlib").EitherN<T, InternalError>} EitherN */ `, ...subContext.objectSets.values(), ...subContext.anonymousFunctions, ]; context.outputFiles.push({ contents: result.join("\n"), relativePath: `./common/anonymous-validators${context.extension}`, }); } /** * * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {import("./utils").ImportCreator} anonymousImports * @param {string} group * @returns {{ sources: string[] }} */ function generateValidatorsForGroup(context, imports, anonymousImports, group) { const data = context.context.structure[group]; const mapping = {}; const sources = []; for (const name of Object.keys(data)) { const type = data[name]; if (["route", "relation", "crud"].indexOf(type.type) !== -1) { continue; } if (context.context.options.useTypescript) { imports.destructureImport( getTypeNameForType(context.context, data[name], "", {}), "../common/types", ); } mapping[name] = createOrUseAnonymousFunction( context, anonymousImports, type, ); imports.destructureImport( mapping[name], `../common/anonymous-validators${context.context.importExtension}`, ); } sources.push(` /** * @template T * @typedef {import("@compas/stdlib").Either<T, AppError>} Either */ `); for (const name of Object.keys(mapping)) { sources.push(js` /** ${formatDocString(data[name].docString, { format: "jsdoc", indentSize: 7, })} * * @param {${generateTypeDefinition(context.context, { type: "any", isOptional: true, })}|${ context.context.options.declareGlobalTypes === false ? `import("../common/types").` : "" }${getTypeNameForType(context.context, data[name], "Input", { useDefaults: false, useConvert: true, })}} value * @param {string|undefined} [propertyPath] * @returns {Either<${ context.context.options.declareGlobalTypes === false ? `import("../common/types").` : "" }${getTypeNameForType(context.context, data[name], "", {})}>} */ export function validate${ data[name].uniqueName }(value, propertyPath = "$") { const result = ${mapping[name]}(value, propertyPath); if (result.errors) { const info = {}; for (const err of result.errors) { if (isNil(info[err.propertyPath])) { info[err.propertyPath] = err; } else if (Array.isArray(info[err.propertyPath])) { info[err.propertyPath].push(err); } else { info[err.propertyPath] = [ info[err.propertyPath], err ]; } } /** @type {{ error: AppError }} */ return { error: AppError.validationError("validator.error", info), }; } /** @type {{ value: ${ context.context.options.declareGlobalTypes === false ? `import("../common/types").` : "" }${getTypeNameForType(context.context, data[name], "", {})}}} */ return { value: result.value }; } `); } return { sources, }; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenType} type * @param {string} valueString * @param {string} propertyPath * @param {string} prefix * @returns {string} */ function generateAnonymousValidatorCall( context, imports, type, valueString, propertyPath, prefix, ) { const inlineCall = createInlineValidator( context, imports, type, valueString, propertyPath, prefix, ); if (!isNil(inlineCall)) { return inlineCall; } const anonFn = createOrUseAnonymousFunction(context, imports, type); return `${prefix} ${anonFn}(${valueString}, ${propertyPath});`; } /** * Get hash for any object, for max 18 properties deep. * Used to have stable output of unchanged validators * * @param {string|Record<string, any>} type * @returns {number} */ function getHashForType(type) { const string = typeof type === "string" ? type : stringifyType(type); return getHashForString(string); } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenType} type */ function createOrUseAnonymousFunction(context, imports, type) { const string = stringifyType(type); // Function for this type already exists if (context.anonymousFunctionMapping.has(string)) { return `anonymousValidator${context.anonymousFunctionMapping.get(string)}`; } const hash = getHashForType(string); const name = `anonymousValidator${hash}`; context.anonymousFunctionMapping.set(string, hash); let isOptional = type.isOptional; let defaultValue = type.defaultValue; let allowNull = type.validator?.allowNull; if (type.type === "reference") { isOptional = isOptional || type.reference.isOptional; defaultValue = defaultValue || type.reference.defaultValue; allowNull = allowNull || type.reference.validator?.allowNull; } const fn = js` /** * @param {*} value * @param {string} propertyPath * @returns {EitherN<${generateTypeDefinition(context.context, type, { useDefaults: true, isCommonFile: true, })}>} */ export function anonymousValidator${hash}(value, propertyPath) { if (isNil(value)) { ${() => { if (isOptional && !isNil(defaultValue)) { return `return { value: ${defaultValue} };`; } else if (isOptional) { return `return { value: ${allowNull ? "value" : undefined} };`; } return ` /** @type {{ errors: InternalError[] }} */ return { errors: [{ propertyPath, key: "validator.${type.type}.undefined", info: {}, }], };`; }} } ${anonymousValidatorForType(context, imports, type)} } `; context.anonymousFunctions.push(fn); return name; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenType} type */ function anonymousValidatorForType(context, imports, type) { switch (type.type) { case "any": return anonymousValidatorAny(context, imports, type); case "anyOf": return anonymousValidatorAnyOf(context, imports, type); case "array": return anonymousValidatorArray(context, imports, type); case "boolean": return anonymousValidatorBoolean(context, imports, type); case "date": return anonymousValidatorDate(context, imports, type); case "file": return anonymousValidatorFile(context, imports, type); case "generic": return anonymousValidatorGeneric(context, imports, type); case "number": return anonymousValidatorNumber(context, imports, type); case "object": return anonymousValidatorObject(context, imports, type); case "reference": return anonymousValidatorReference(context, imports, type); case "string": return anonymousValidatorString(context, imports, type); case "uuid": return anonymousValidatorUuid(context, imports); default: return ` /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.${type.type}.invalidValidator", info: { uniqueName: "${type.uniqueName}", } } ] };`; } } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenAnyType} type */ function anonymousValidatorAny(context, imports, type) { if (isNil(type.rawValidator)) { return `return { value };`; } if ( !isNil(type.rawValidatorImport.typeScript) && context.context.options.useTypescript ) { imports.rawImport(type.rawValidatorImport.typeScript); } else if ( !isNil(type.rawValidatorImport.javaScript) && !context.context.options.useTypescript ) { imports.rawImport(type.rawValidatorImport.javaScript); } return js` if (!${type.rawValidator}(value)) { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.any.custom", info: {}, } ] }; } return { value }; `; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenAnyOfType} type */ function anonymousValidatorAnyOf(context, imports, type) { return js` /** @type {InternalError[]} */ let errors = []; /** @type {EitherN<${generateTypeDefinition(context.context, type, { useDefaults: true, isCommonFile: true, })}>} */ let result = { errors: [] }; ${type.values.map((it) => { return js` ${generateAnonymousValidatorCall( context, imports, it, "value", "propertyPath", "result = ", )} if (result.errors) { errors.push(result.errors[0]); } else { return result; } `; })} for (const err of errors) { err.info.via = "validator.anyOf"; } return { errors }; `; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenArrayType} type */ function anonymousValidatorArray(context, imports, type) { return js` ${() => { if (type.validator.convert) { return js` if (!Array.isArray(value)) { value = [ value ]; } `; } }} if (!Array.isArray(value)) { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.array.type", info: {}, } ], }; } ${() => { if (!isNil(type.validator.min)) { return js` if (value.length < ${type.validator.min}) { const min = ${type.validator.min}; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.array.min", info: { min }, } ], }; } `; } }} ${() => { if (!isNil(type.validator.max)) { return js` if (value.length > ${type.validator.max}) { const max = ${type.validator.max}; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.array.max", info: { max }, } ], }; } `; } }} const result = Array.from({ length: value.length }); let errors = []; for (let i = 0; i < value.length; ++i) { ${generateAnonymousValidatorCall( context, imports, type.values, `value[i]`, `propertyPath + "[" + i + "]"`, `const arrVar = `, )} if (arrVar.errors) { errors.push(...arrVar.errors); } else { result[i] = arrVar.value; } } if (errors.length > 0) { /** @type {{ errors: InternalError[] }} */ return { errors, }; } return { value: result }; `; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenBooleanType} type */ function anonymousValidatorBoolean(context, imports, type) { return js` ${() => { if (type.validator.convert) { return js` if (typeof value !== "boolean") { if (value === "true" || value === 1) { value = true; } else if (value === "false" || value === 0) { value = false; } } `; } }} if (typeof value !== "boolean") { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.boolean.type", info: {}, } ], }; } ${() => { if (type.oneOf !== undefined) { return js` if (value !== ${type.oneOf}) { const oneOf = ${type.oneOf}; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.boolean.oneOf", info: { oneOf, value }, } ], }; } `; } }} return { value }; `; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenDateType} type */ function anonymousValidatorDate(context, imports, type) { const dateOnlyType = { ...TypeBuilder.baseData, type: "string", validator: { min: 10, max: 10, pattern: "/^\\d{4}-((0[1-9])|(1[0-2]))-((0[1-9])|([1-2][0-9])|(3[0-1]))$/gi", }, }; const timeOnlyType = { ...TypeBuilder.baseData, type: "string", validator: { min: 5, max: 12, pattern: "/^(([0-1][0-9])|(2[0-3])):[0-5][0-9](:[0-5][0-9](\\.\\d{3})?)?$/gi", }, }; const head = js` if (typeof value !== "string" && typeof value !== "number" && !(value instanceof Date)) { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.date.invalid", info: {}, } ] }; } `; if (type.specifier === "dateOnly") { return ( head + generateAnonymousValidatorCall( context, imports, dateOnlyType, "value", "propertyPath", "return ", ) ); } else if (type.specifier === "timeOnly") { return ( head + generateAnonymousValidatorCall( context, imports, timeOnlyType, "value", "propertyPath", "return ", ) ); } return js` ${head} const date = new Date(value); if (isNaN(date.getTime())) { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.date.invalid", info: {}, } ] }; } ${() => { if (!isNil(type.validator.min)) { const time = new Date(type.validator.min).getTime(); return js` // ${type.validator.min} if (date.getTime() < ${time}) { const min = "${type.validator.min}"; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.date.dateMin", info: { min }, } ] }; } `; } }} ${() => { if (!isNil(type.validator.max)) { const time = new Date(type.validator.max).getTime(); return js` // ${type.validator.max} if (date.getTime() > ${time}) { const max = "${type.validator.max}"; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.date.dateMax", info: { max }, } ] }; } `; } }} ${() => { if (type.validator.inFuture === true) { return js` if (date.getTime() < Date.now()) { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.date.future", info: {}, } ] }; } `; } }} ${() => { if (type.validator.inPast === true) { return js` if (date.getTime() > Date.now()) { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.date.past", info: {}, } ] }; } `; } }} return { value: date, }; `; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenFileType} type * @returns {string} */ function anonymousValidatorFile(context, imports, type) { if (context.context.options.isBrowser) { return js` // Blob result from api client if (value instanceof Blob) { return { value }; } // Blob input as post argument if (value && value.blob instanceof Blob) { return { value }; } /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.file.unknown", info: {}, } ] }; `; } return js` // ReadableStream input to api call if (typeof value.data?.pipe === "function" && typeof value.data?._read === "function") { return { value }; } // ReadableStream as output of an api call if (typeof value?.pipe === "function" && typeof value?._read === "function") { return { value }; } // Object as parsed by the file body parsers if (typeof value?.filepath === "string" && typeof value?.mimetype === "string" && typeof value?.size === "number") { ${() => { if (!isNil(type.validator?.mimeTypes)) { return js` if (${type.validator.mimeTypes .map((it) => `value.mimetype !== "${it}"`) .join(" && ")}) { const mimeTypes = [ " ${type.validator.mimeTypes.join(`", "`)} " ]; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.file.mimeType", info: { mimeTypes }, } ] }; } `; } }} return { value }; } /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.file.unknown", info: {}, } ] }; `; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenGenericType} type */ function anonymousValidatorGeneric(context, imports, type) { return js` if (typeof value !== "object") { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.generic.type", info: {}, } ] }; } const result = Object.create(null); let errors = []; for (const key of Object.keys(value)) { ${generateAnonymousValidatorCall( context, imports, type.keys, "key", `propertyPath + ".$key[" + key + "]"`, `const genericKey = `, )} if (genericKey.errors) { errors.push(...genericKey.errors); continue; } ${generateAnonymousValidatorCall( context, imports, type.values, "value[key]", `propertyPath + ".$value[" + key + "]"`, `const genericValue =`, )} if (genericValue.errors) { errors.push(...genericValue.errors); } else { result[genericKey.value] = genericValue.value; } } if (errors.length > 0) { return { errors }; } return { value: result }; `; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenNumberType} type */ function anonymousValidatorNumber(context, imports, type) { return js` ${() => { if (type.validator.convert) { return js` if (typeof value !== "number") { value = Number(value); } `; } }} if (typeof value !== "number" || isNaN(value) || !isFinite(value)) { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.number.type", info: {}, } ] }; } ${() => { if (!type.validator.floatingPoint) { return js` if (!Number.isInteger(value)) { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.number.integer", info: {}, } ] }; } `; } }} ${() => { if (!isNil(type.validator.min)) { return js` if (value < ${type.validator.min}) { const min = ${type.validator.min}; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.number.min", info: { min }, } ] }; } `; } }} ${() => { if (!isNil(type.validator.max)) { return js` if (value > ${type.validator.max}) { const max = ${type.validator.max}; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.number.max", info: { max }, } ] }; } `; } }} ${() => { if (!isNil(type.oneOf)) { return js` if (${type.oneOf.map((it) => `value !== ${it}`).join(" && ")}) { const oneOf = [ ${type.oneOf.join(", ")} ]; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.number.oneOf", info: { oneOf, value }, } ] }; } `; } }} return { value }; `; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenObjectType} type */ function anonymousValidatorObject(context, imports, type) { const hash = getHashForType(type); if (type.validator.strict && !context.objectSets.has(hash)) { context.objectSets.set( hash, `const objectKeys${hash} = new Set(["${Object.keys(type.keys).join( `", "`, )}"])`, ); } return js` if (typeof value !== "object") { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.object.type", info: {}, } ], }; } const result = Object.create(null); let errors = []; ${() => { // Setup a keySet, so we can error when extra keys are present if (type.validator.strict) { return js` for (const key of Object.keys(value)) { if (!objectKeys${hash}.has(key)) { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.object.strict", info: { expectedKeys: [ ...objectKeys${hash} ], foundKeys: [ ...Object.keys(value) ], }, } ], }; } } `; } }} ${Object.keys(type.keys) .map((it) => { const fn = createOrUseAnonymousFunction( context, imports, type.keys[it], ); return ` { const validatorResult = ${fn}(value["${it}"], \`$\{propertyPath}.${it}\`); if (validatorResult.errors) { errors.push(...validatorResult.errors); } else { result["${it}"] = validatorResult.value; } } `; }) .join("\n")} if (errors.length > 0) { return { errors }; } return { value: result }; `; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenReferenceType} type */ function anonymousValidatorReference(context, imports, type) { return generateAnonymousValidatorCall( context, imports, type.reference, "value", "propertyPath", "return ", ); } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenStringType} type */ function anonymousValidatorString(context, imports, type) { return js` ${() => { if (type.validator.convert) { return js` if (typeof value !== "string") { value = String(value); } `; } }} if (typeof value !== "string") { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.string.type", info: {}, } ], }; } ${() => { if (type.validator.trim) { return js` value = value.trim(); `; } }} ${() => { // Special case to default to undefined on empty & optional strings if (type.isOptional && !isNil(type.defaultValue)) { return js` if (value.length === 0) { return { value: ${type.defaultValue} }; } `; } else if (type.isOptional) { return js` if (value.length === 0) { return { value: ${type.validator?.allowNull ? "null" : "undefined"} }; } `; } }} ${() => { if ( !isNil(type.validator.min) && (!type.isOptional || type.validator.min !== 1) ) { return js` if (value.length < ${type.validator.min}) { const min = ${type.validator.min}; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.string.min", info: { min, }, } ], }; } `; } }} ${() => { if (!isNil(type.validator.max)) { return js` if (value.length > ${type.validator.max}) { const max = ${type.validator.max}; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.string.max", info: { max }, } ], }; } `; } }} ${() => { if (type.validator.upperCase) { return js` value = value.toUpperCase(); `; } }} ${() => { if (type.validator.lowerCase) { return js` value = value.toLowerCase(); `; } }} ${() => { if (!isNil(type.oneOf)) { return js` if (${type.oneOf.map((it) => `value !== "${it}"`).join(" && ")}) { const oneOf = [ "${type.oneOf.join('", "')}" ]; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.string.oneOf", info: { oneOf, value }, } ], }; } `; } }} ${() => { if (!isNil(type.validator.pattern)) { return js` if (!${type.validator.pattern}.test(value)) { /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.string.pattern", info: {}, } ], }; } `; } }} ${() => { if (!isNil(type.validator.disallowedCharacters)) { return js` for (const char of value) { if (${type.validator.disallowedCharacters .map((it) => `char === "${it}"`) .join(" || ")}) { const disallowedCharacters = [ "${type.validator.disallowedCharacters.join('", "')}" ]; /** @type {{ errors: InternalError[] }} */ return { errors: [ { propertyPath, key: "validator.string.disallowedCharacter", info: { disallowedCharacters, character: char }, } ], }; } } `; } }} return { value }; `; } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports */ function anonymousValidatorUuid(context, imports) { const stringType = { ...TypeBuilder.baseData, type: "string", validator: { min: 36, max: 36, lowerCase: true, trim: true, pattern: "/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}$/gi", }, }; return generateAnonymousValidatorCall( context, imports, stringType, "value", "propertyPath", "return ", ); } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenType} type * @param {string} valueString * @param {string} propertyPath * @param {string} prefix * @returns {string|undefined} */ function createInlineValidator( context, imports, type, valueString, propertyPath, prefix, ) { if (type.type !== "reference") { return undefined; } // Just don't deal with default values if (type.isOptional && !isNil(type.defaultValue)) { return undefined; } // Don't deal with nullable types, and converting values if (type.validator?.allowNull || type.validator?.convert) { return undefined; } return inlineValidatorReference( context, imports, type, valueString, propertyPath, prefix, ); } /** * @param {ValidatorContext} context * @param {import("./utils").ImportCreator} imports * @param {CodeGenReferenceType} type * @param {string} valueString * @param {string} propertyPath * @param {string} prefix * @returns {string} */ function inlineValidatorReference( context, imports, type, valueString, propertyPath, prefix, ) { if (!type.isOptional || (type.isOptional && type.reference.isOptional)) { return generateAnonymousValidatorCall( context, imports, type.reference, valueString, propertyPath, prefix, ); } }