@lbu/code-gen
Version:
Generate various boring parts of your server
851 lines (759 loc) • 21.3 kB
JavaScript
import { inspect } from "util";
import { isNil } from "@lbu/stdlib";
import { TypeBuilder } from "../builders/index.js";
import { js } from "./tag/index.js";
import { generateTypeDefinition, getTypeNameForType } from "./types.js";
import { importCreator } from "./utils.js";
/**
* @name ValidatorContext
* @typedef {object}
* @property {CodeGenContext} context
* @property {boolean} collectErrors
* @property {number} anonymousFunctionIdx
* @property {Map<string, number>} anonymousFunctionMapping
* @property {string[]} anonymousFunctions
*/
/**
* @param {CodeGenContext} context
*/
export function generateValidatorFile(context) {
/**
* @type {ValidatorContext}
*/
const subContext = {
context,
collectErrors: !context.options.isNodeServer,
anonymousFunctionIdx: 0,
anonymousFunctionMapping: new Map(),
anonymousFunctions: [],
};
addUtilitiesToAnonymousFunctions(subContext);
const imports = importCreator();
const rootExports = [];
const validatorSources = [];
for (const group of Object.keys(context.structure)) {
const { exportNames, sources } = generateValidatorsForGroup(
subContext,
imports,
group,
);
rootExports.push(...exportNames);
validatorSources.push(...sources);
}
context.outputFiles.push({
contents: subContext.anonymousFunctions.join("\n"),
relativePath: `./anonymous-validators${context.extension}`,
});
context.outputFiles.push({
contents: js`
${imports.print()}
${validatorSources}
`,
relativePath: `./validators${context.extension}`,
});
context.rootExports.push(
`export { ${rootExports.join(",\n ")} } from "./validators${
context.importExtension
}";`,
);
}
/**
* @param {ValidatorContext} context
* @param {string} output
* @returns {string}
*/
function withTypescript(context, output) {
if (context.context.options.useTypescript) {
return output ?? "";
}
return "";
}
/**
*
* @param {ValidatorContext} context
* @param {ImportCreator} imports
* @param {string} group
* @returns {{ exportNames: string[], sources: string[] }}
*/
export function generateValidatorsForGroup(context, imports, group) {
const data = context.context.structure[group];
const mapping = {};
const exportNames = [];
const sources = [];
for (const name of Object.keys(data)) {
const type = data[name];
if (["route", "relation"].indexOf(type.type) !== -1) {
continue;
}
if (context.context.options.useTypescript) {
imports.destructureImport(
getTypeNameForType(context.context, data[name], "", {}),
"./types",
);
}
mapping[name] = createOrUseAnonymousFunction(context, imports, type, true);
exportNames.push(`validate${type.uniqueName}`);
}
for (const name of Object.keys(mapping)) {
sources.push(js`
/**
* ${data[name].docString ?? ""}
* @param {${generateTypeDefinition(context.context, {
type: "any",
isOptional: true,
})}} value
* @param {string|undefined} [propertyPath]
${() => {
if (context.collectErrors) {
return `* @returns {{ data: ${getTypeNameForType(
context.context,
data[name],
"",
{},
)} | undefined, errors: (*[])|undefined}}`;
}
return js`*
@returns {
${getTypeNameForType(context.context, data[name], "", {})}
}`;
}}
*/
export function validate${data[name].uniqueName}(
value${withTypescript(context, ": any")},
propertyPath = "$"
)
${withTypescript(
context,
`: { data: ${getTypeNameForType(
context.context,
data[name],
"",
{},
)}, errors: undefined } | { data: undefined, errors: any[] }`,
)}
{
const errors${withTypescript(context, ": any[]")} = [];
const data = ${mapping[name]}(value, propertyPath, errors);
${() => {
if (context.collectErrors) {
return js`
if (errors.length > 0) {
return { data: undefined, errors };
} else {
return { data${withTypescript(
context,
": data!",
)}, errors: undefined };
}
`;
}
return js`
if (errors.length > 0) {
throw errors[0];
} else {
return data;
}
`;
}}
}
`);
}
return {
exportNames,
sources,
};
}
/**
* @param {ValidatorContext} context
*/
function addUtilitiesToAnonymousFunctions(context) {
context.anonymousFunctions.push(js`
/**
* @param {*} value
* @returns {boolean}
*/
export function isNil(value
${withTypescript(context, ": any")}
)
{
return value === undefined || value === null;
}
/**
* @name {ValidationErrorFn}
* This function should not throw as the corresponding validator will do that
* @typedef {function(string,any): Error}
*/
${withTypescript(
context,
"type ValidationErrorFn = (key: string, info: any) => any",
)}
/** @type {ValidationErrorFn} */
let errorFn = (key${withTypescript(context, ": string")},
info${withTypescript(context, ": any")}
) => {
const err
${withTypescript(
context,
": any",
)} = new Error(\`ValidationError: $\{key}\`);
err.key = key;
err.info = info;
return err;
};
/**
* @param {string} type
* @param {string} key
* @param {*} info
*/
export function buildError(type${withTypescript(context, ": string")},
key${withTypescript(context, ": string")},
info
${withTypescript(context, ": any")}
)
{
return errorFn(\`validator.$\{type}.$\{key}\`, info);
}
/**
* Set a different error function, for example AppError.validationError
* @param {ValidationErrorFn} fn
*/
export function validatorSetError(fn${withTypescript(
context,
": ValidationErrorFn",
)}) {
errorFn = fn;
}
`);
context.context.rootExports.push(
`export { validatorSetError } from "./anonymous-validators${context.context.importExtension}";`,
);
}
/**
* @param {ValidatorContext} context
* @param {ImportCreator} imports
* @param {CodeGenType} type
* @param {boolean} [isTypeRoot=false]
*/
export function createOrUseAnonymousFunction(
context,
imports,
type,
isTypeRoot = false,
) {
const string = inspect(type, { colors: false, depth: 15 });
// Function for this type already exists
if (context.anonymousFunctionMapping.has(string)) {
const name = `anonymousValidator${context.anonymousFunctionMapping.get(
string,
)}`;
if (isTypeRoot) {
imports.destructureImport(
name,
`./anonymous-validators${context.context.importExtension}`,
);
}
return name;
}
const idx = context.anonymousFunctionIdx++;
const name = `anonymousValidator${idx}`;
context.anonymousFunctionMapping.set(string, idx);
if (isTypeRoot) {
imports.destructureImport(
name,
`./anonymous-validators${context.context.importExtension}`,
);
}
const fn = js`
/**
* @param {*} value
* @param {string} propertyPath
* @param {*[]} errors
* @param {string} parentType
* @returns {${generateTypeDefinition(context.context, type, {
useDefaults: true,
})}|undefined}
*/
export function anonymousValidator${idx}(value${withTypescript(
context,
": any",
)},
propertyPath${withTypescript(
context,
": string",
)},
errors${withTypescript(
context,
": any[]",
)} = [],
parentType${withTypescript(
context,
": string",
)} = "${type.type}",
) {
if (isNil(value)) {
${() => {
if (type.isOptional && type.defaultValue) {
return `return ${type.defaultValue}`;
} else if (type.isOptional) {
return `return value`;
}
return buildError("undefined", "{ propertyPath }");
}}
}
${anonymousValidatorForType(context, imports, type)}
}
`;
context.anonymousFunctions.push(fn);
return name;
}
/**
* @param {ValidatorContext} context
* @param {ImportCreator} imports
* @param {CodeGenType} type
*/
function anonymousValidatorForType(context, imports, type) {
switch (type.type) {
case "any":
// TODO: Implement custom imports?
return `return value;`;
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);
case "file":
// TODO: Implement for possible locations
return `return value;`;
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);
}
}
/**
* @param {ValidatorContext} context
* @param {ImportCreator} imports
* @param {CodeGenAnyOfType} type
*/
function anonymousValidatorAnyOf(context, imports, type) {
return js`
let errorCount = 0;
const subErrors${withTypescript(context, ": any[]")} = [];
let result = undefined;
${type.values.map((it) => {
const validator = createOrUseAnonymousFunction(context, imports, it);
// Only returns the first error for anyOf types
return js`
result = ${validator}(value, propertyPath, subErrors);
if (subErrors.length === errorCount) {
return result;
}
subErrors.splice(errorCount + 1, subErrors.length - errorCount);
errorCount = subErrors.length;
`;
})}
${buildError("type", "{ propertyPath, errors: subErrors }")}
`;
}
/**
* @param {ValidatorContext} context
* @param {ImportCreator} imports
* @param {CodeGenArrayType} type
*/
function anonymousValidatorArray(context, imports, type) {
const validator = createOrUseAnonymousFunction(context, imports, type.values);
return js`
${() => {
if (type.validator.convert) {
return js`
if (!Array.isArray(value)) {
value = [ value ];
}
`;
}
}}
if (!Array.isArray(value)) {
${buildError("type", "{ propertyPath }")}
}
${() => {
if (!isNil(type.validator.min)) {
return js`
if (value.length < ${type.validator.min}) {
const min = ${type.validator.min};
${buildError("min", "{ propertyPath, min }")}
}
`;
}
}}
${() => {
if (!isNil(type.validator.max)) {
return js`
if (value.length > ${type.validator.max}) {
const max = ${type.validator.max};
${buildError("max", "{ propertyPath, max }")}
}
`;
}
}}
const result = Array.from({ length: value.length });
for (let i = 0; i < value.length; ++i) {
result[i] = ${validator}(value[i], propertyPath + "[" + i + "]", errors);
}
return result;
`;
}
/**
* @param {ValidatorContext} context
* @param {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") {
${buildError("type", "{ propertyPath }")}
}
${() => {
if (type.oneOf !== undefined) {
return js`
if (value !== ${type.oneOf}) {
const oneOf = ${type.oneOf};
${buildError("oneOf", "{ propertyPath, oneOf }")}
}
`;
}
}}
return value;
`;
}
/**
* @param {ValidatorContext} context
* @param {ImportCreator} imports
*/
function anonymousValidatorDate(context, imports) {
const validator = createOrUseAnonymousFunction(context, imports, {
...TypeBuilder.getBaseData(),
type: "string",
validator: {
min: 24,
max: 29,
pattern:
"/^(\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d\\.\\d+([+-][0-2]\\d:[0-5]\\d|Z))|(\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z))|(\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d([+-][0-2]\\d:[0-5]\\d|Z))$/gi",
},
});
return js`
if (typeof value === "string") {
value = ${validator}(value, propertyPath, errors, parentType);
}
try {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date;
}
} catch {
${buildError("invalid", "{ propertyPath }")}
}
${buildError("invalid", "{ propertyPath }")}
`;
}
/**
* @param {ValidatorContext} context
* @param {ImportCreator} imports
* @param {CodeGenGenericType} type
*/
function anonymousValidatorGeneric(context, imports, type) {
const keyValidator = createOrUseAnonymousFunction(
context,
imports,
type.keys,
);
const valueValidator = createOrUseAnonymousFunction(
context,
imports,
type.values,
);
return js`
if (typeof value !== "object") {
${buildError("type", "{ propertyPath }")}
}
const result = Object.create(null);
for (const key of Object.keys(value)) {
const genericKey = ${keyValidator}(key, propertyPath + ".$key[" + key + "]", errors);
if (genericKey !== undefined) {
result[genericKey] = ${valueValidator}(value[key], propertyPath + ".$value[" + key + "]", errors);
}
}
return result;
`;
}
/**
* @param {ValidatorContext} context
* @param {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)) {
${buildError("type", "{ propertyPath }")}
}
${() => {
if (!type.validator.floatingPoint) {
return js`
if (!Number.isInteger(value)) {
${buildError("integer", "{ propertyPath }")}
}
`;
}
}}
${() => {
if (!isNil(type.validator.min)) {
return js`
if (value < ${type.validator.min}) {
const min = ${type.validator.min};
${buildError("min", "{ propertyPath, min }")}
}
`;
}
}}
${() => {
if (!isNil(type.validator.max)) {
return js`
if (value > ${type.validator.max}) {
const max = ${type.validator.max};
${buildError("max", "{ propertyPath, max }")}
}
`;
}
}}
${() => {
if (!isNil(type.oneOf)) {
return js`
if (${type.oneOf.map((it) => `value !== ${it}`).join(" && ")}) {
const oneOf = [ ${type.oneOf.join(", ")} ];
${buildError("oneOf", "{ propertyPath, oneOf }")}
}
`;
}
}}
return value;
`;
}
/**
* @param {ValidatorContext} context
* @param {ImportCreator} imports
* @param {CodeGenObjectType} type
*/
function anonymousValidatorObject(context, imports, type) {
return js`
if (typeof value !== "object") {
${buildError("type", "{ propertyPath }")}
}
const result = Object.create(null);
${() => {
// Setup a keySet, so we can error when extra keys are present
if (type.validator.strict) {
return js`const keySet = new Set(Object.keys(value));`;
}
}}
${() => {
return Object.keys(type.keys).map((it) => {
const validator = createOrUseAnonymousFunction(
context,
imports,
type.keys[it],
);
return js`
result["${it}"] = ${validator}(value["${it}"], propertyPath + ".${it}", errors);
${() => {
if (type.validator.strict) {
return js`keySet.delete("${it}")`;
}
}}
`;
});
}}
${() => {
if (type.validator.strict) {
return js`
if (keySet.size !== 0) {
const extraKeys = [ ...keySet ];
${buildError("strict", "{ propertyPath, extraKeys }")}
}
`;
}
}}
return result;
`;
}
/**
* @param {ValidatorContext} context
* @param {ImportCreator} imports
* @param {CodeGenReferenceType} type
*/
function anonymousValidatorReference(context, imports, type) {
const validator = createOrUseAnonymousFunction(
context,
imports,
type.reference,
);
return js`
return ${validator}(value, propertyPath, errors);
`;
}
/**
* @param {ValidatorContext} context
* @param {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") {
${buildError("type", "{ propertyPath }")}
}
${() => {
if (type.validator.trim) {
return js`
value = value.trim();
`;
}
}}
${() => {
// Special case to default to undefined on empty & optional strings
if (type.isOptional && type.defaultValue) {
return js`
if (value.length === 0) {
return ${type.defaultValue};
}
`;
} else if (type.isOptional) {
return js`
if (value.length === 0) {
return ${type.validator?.allowNull ? "null" : "undefined"};
}
`;
}
}}
${() => {
if (!isNil(type.validator.min)) {
return js`
if (value.length < ${type.validator.min}) {
const min = ${type.validator.min};
${buildError("min", "{ propertyPath, min }")}
}
`;
}
}}
${() => {
if (!isNil(type.validator.max)) {
return js`
if (value.length > ${type.validator.max}) {
const max = ${type.validator.max};
${buildError("max", "{ propertyPath, 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('", "')}" ];
${buildError("oneOf", "{ propertyPath, oneOf }")}
}
`;
}
}}
${() => {
if (!isNil(type.validator.pattern)) {
return js`
if (!${type.validator.pattern}.test(value)) {
${buildError("pattern", "{ propertyPath }")}
}
`;
}
}}
return value;
`;
}
/**
* @param {ValidatorContext} context
* @param {ImportCreator} imports
*/
function anonymousValidatorUuid(context, imports) {
const validator = createOrUseAnonymousFunction(context, imports, {
...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 js`
return ${validator}(value, propertyPath, errors, parentType);
`;
}
function buildError(key, info) {
return js`
errors.push(buildError(parentType, "${key}", ${info}));
return undefined;
`;
}