diff --git a/libraries/validation-tools/index.js b/libraries/validation-tools/index.js index bce67b5e41..b996e1905c 100644 --- a/libraries/validation-tools/index.js +++ b/libraries/validation-tools/index.js @@ -2,11 +2,13 @@ const { ParamsError } = require('./Errors') const { z } = require('zod') const { zz } = require('./zodHelpers') const { validateReq } = require('./validateReq') +const { validateSchema } = require('./validateSchema') const { handleValidationError } = require('./handleValidationError') module.exports = { z, zz, + validateSchema, validateReq, handleValidationError, ParamsError, diff --git a/libraries/validation-tools/validateSchema.js b/libraries/validation-tools/validateSchema.js new file mode 100644 index 0000000000..f765e52033 --- /dev/null +++ b/libraries/validation-tools/validateSchema.js @@ -0,0 +1,60 @@ +// @ts-check +const { isZodErrorLike } = require('zod-validation-error') + +/** + * @typedef {import('zod').ZodType} ZodType + */ +/** + * @template T + * @typedef {import('zod').output} output + */ + +/** + * A helper function to safely get a nested value from an object + * using a path array (e.g., ["query", "resource_type"]) + */ +function getPathValue(data, path) { + let current = data + for (const key of path) { + if (current === null || typeof current !== 'object') { + return undefined + } + current = current[key] + } + return current +} + +const isRequiredError = (issue, value) => + value === undefined && + (issue.code === 'invalid_type' || issue.code === 'invalid_union') + +/** + * Validates data against a Zod schema and throws a user-friendly error. + * + * @template {ZodType} T + * @param {T} schema - The Zod schema + * @param {unknown} data - The data to validate + * @returns {output} The validated (and transformed) data + */ +function validateSchema(schema, data) { + try { + return schema.parse(data) + } catch (err) { + if (isZodErrorLike(err)) { + const errorMessages = err.issues.map(issue => { + const value = getPathValue(data, issue.path) + const fieldName = String(issue.path[issue.path.length - 1]) + if (isRequiredError(issue, value)) { + return `"${fieldName}" is required` + } + return `"${fieldName}" - ` + issue.message + }) + + throw new Error(errorMessages.join('; ')) + } + + throw err + } +} + +module.exports = { validateSchema } diff --git a/libraries/validation-tools/zodHelpers.js b/libraries/validation-tools/zodHelpers.js index 5e9d8cb3b3..9506e679bd 100644 --- a/libraries/validation-tools/zodHelpers.js +++ b/libraries/validation-tools/zodHelpers.js @@ -3,6 +3,13 @@ const mongodb = require('mongodb') const { ObjectId } = mongodb +const dateWithTransform = (schema, allowNull = false) => { + return schema.transform(dt => { + if (allowNull && !dt) return null + return dt instanceof Date ? dt : new Date(dt) + }) +} + const zz = { objectId: () => z.string().refine(ObjectId.isValid, { message: 'invalid Mongo ObjectId' }), @@ -12,6 +19,14 @@ const zz = { .refine(ObjectId.isValid, { message: 'invalid Mongo ObjectId' }) .transform(val => new ObjectId(val)), hex: () => z.string().regex(/^[0-9a-f]*$/), + datetime: () => dateWithTransform(z.union([z.iso.datetime(), z.date()])), + datetimeNullable: () => + dateWithTransform(z.union([z.iso.datetime(), z.date(), z.null()]), true), + datetimeNullish: () => + dateWithTransform( + z.union([z.iso.datetime(), z.date(), z.null(), z.undefined()]), + true + ), } module.exports = { zz } diff --git a/package-lock.json b/package-lock.json index 37c314b79d..07168e9695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53543,15 +53543,14 @@ "@overleaf/redis-wrapper": "*", "@overleaf/settings": "*", "@overleaf/stream-utils": "^0.1.0", + "@overleaf/validation-tools": "*", "bluebird": "^3.7.2", "body-parser": "^1.20.3", "bull": "^3.18.0", "camelcase-keys": "^4.2.0", - "celebrate": "^15.0.3", "csv": "^5.4.0", "east": "^2.0.3", "express": "^4.21.2", - "joi": "^17.12.0", "json2csv": "^4.5.4", "lodash": "^4.17.21", "minimist": "^1.2.7",