From 67959e29ac0ab23236c3607793d360e1ed581d75 Mon Sep 17 00:00:00 2001 From: Costin-Robert Sin Date: Tue, 19 Dec 2023 11:18:45 +0200 Subject: [PATCH] Check Yaml Configuration correctness using Zod --- package-lock.json | 12 +- package.json | 3 +- src/models/yamlProjectConfiguration.ts | 413 +++++++++++++------------ src/utils/configs.ts | 2 +- tests/projectConfiguration.test.ts | 5 +- 5 files changed, 227 insertions(+), 208 deletions(-) diff --git a/package-lock.json b/package-lock.json index 83131746f..21ae2439f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "@babel/preset-env": "^7.23.5", "@babel/preset-typescript": "^7.23.3", "@babel/traverse": "^7.23.5", - "@rollup/rollup-linux-riscv64-gnu": "4.9.1", "@sentry/node": "^7.74.1", "@sentry/profiling-node": "~1.2.6", "@types/body": "^5.1.4", @@ -67,7 +66,8 @@ "uuid": "^9.0.1", "whatwg-mimetype": "~3.0.0", "which": "^4.0.0", - "yaml": "^2.3.4" + "yaml": "^2.3.4", + "zod": "^3.22.4" }, "bin": { "genezio": "build/src/index.js" @@ -12208,6 +12208,14 @@ "engines": { "node": ">= 10" } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 3d61d8e7c..865d24821 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,8 @@ "uuid": "^9.0.1", "whatwg-mimetype": "~3.0.0", "which": "^4.0.0", - "yaml": "^2.3.4" + "yaml": "^2.3.4", + "zod": "^3.22.4" }, "repository": { "type": "git", diff --git a/src/models/yamlProjectConfiguration.ts b/src/models/yamlProjectConfiguration.ts index f35cb38e5..9c1a3a5c1 100644 --- a/src/models/yamlProjectConfiguration.ts +++ b/src/models/yamlProjectConfiguration.ts @@ -1,11 +1,11 @@ import path from "path"; -import yaml, { parse } from "yaml"; -import { fileExists, getFileDetails, readUTF8File, writeToFile } from "../utils/file.js"; +import yaml from "yaml"; +import { getFileDetails, writeToFile } from "../utils/file.js"; import { regions } from "../utils/configs.js"; import { isValidCron } from "cron-validator"; -import log from "loglevel"; -import { CloudProviderIdentifier, cloudProviders } from "./cloudProviderIdentifier.js"; -import { NodeOptions } from "./nodeRuntime.js"; +import { CloudProviderIdentifier } from "./cloudProviderIdentifier.js"; +import { DEFAULT_NODE_RUNTIME, NodeOptions } from "./nodeRuntime.js"; +import zod from "zod"; export enum TriggerType { jsonrpc = "jsonrpc", @@ -13,6 +13,48 @@ export enum TriggerType { http = "http", } +interface RawYamlConfiguration { + [key: string]: + | string + | number + | boolean + | null + | undefined + | RawYamlConfiguration + | Array; +} + +function zodFormatError(e: zod.ZodError) { + let errorString = ""; + const issueMap = new Map(); + + for (const issue of e.issues) { + if (issueMap.has(issue.path.join("."))) { + issueMap.get(issue.path.join("."))?.push(issue.message); + } else { + issueMap.set(issue.path.join("."), [issue.message]); + } + } + + const formErrors = issueMap.get(""); + if (formErrors && formErrors.length > 0) { + errorString += "Form errors:\n"; + for (const error of formErrors) { + errorString += `\t- ${error}\n`; + } + } + + const fieldErrors = Array.from(issueMap.entries()).filter((entry) => entry[0] !== ""); + for (const [field, errors] of fieldErrors) { + if (errors === undefined) continue; + + errorString += `Field \`${field}\`:\n`; + errorString += `\t- ${errors.join("\n\t- ")}\n`; + } + + return errorString; +} + export function getTriggerTypeFromString(string: string): TriggerType { if (string && !TriggerType[string as keyof typeof TriggerType]) { const triggerTypes: string = Object.keys(TriggerType).join(", "); @@ -71,53 +113,6 @@ export class YamlMethodConfiguration { this.type = type ?? TriggerType.jsonrpc; this.cronString = cronString; } - - static async create( - methodConfigurationYaml: any, - classType: TriggerType, - ): Promise { - if (!methodConfigurationYaml.name) { - throw new Error("Missing method name in configuration file."); - } - - if ( - methodConfigurationYaml.type && - !TriggerType[methodConfigurationYaml.type as keyof typeof TriggerType] - ) { - throw new Error("The method's type is incorrect."); - } - - let type = classType; - if (methodConfigurationYaml.type) { - type = TriggerType[methodConfigurationYaml.type as keyof typeof TriggerType]; - } - - if (type == TriggerType.cron && !methodConfigurationYaml.cronString) { - throw new Error("The cron method is missing a cron string property."); - } - - // Check cron string format - if (type == TriggerType.cron) { - if (!isValidCron(methodConfigurationYaml.cronString)) { - throw new Error( - "The cron string is not valid. Check https://crontab.guru/ for more information.", - ); - } - - const cronParts = methodConfigurationYaml.cronString.split(" "); - if (cronParts[2] != "*" && cronParts[4] != "*") { - throw new Error( - "The cron string is not valid. The day of the month and day of the week cannot be specified at the same time.", - ); - } - } - - return new YamlMethodConfiguration( - methodConfigurationYaml.name, - type, - methodConfigurationYaml.cronString, - ); - } } export class YamlClassConfiguration { @@ -157,39 +152,11 @@ export class YamlClassConfiguration { return TriggerType.jsonrpc; } - - static async create(classConfigurationYaml: any): Promise { - if (!classConfigurationYaml.path) { - throw new Error("Path is missing from class."); - } - - let triggerType = TriggerType.jsonrpc; - - if (classConfigurationYaml.type) { - triggerType = getTriggerTypeFromString(classConfigurationYaml.type); - } - - const unparsedMethods: any[] = classConfigurationYaml.methods || []; - const methods = await Promise.all( - unparsedMethods.map((method: any) => - YamlMethodConfiguration.create(method, triggerType), - ), - ); - const language = path.parse(classConfigurationYaml.path).ext; - - return new YamlClassConfiguration( - classConfigurationYaml.path, - triggerType, - language, - methods, - classConfigurationYaml.name, - ); - } } export type YamlFrontend = { path: string; - subdomain: string | undefined; + subdomain?: string; }; export class YamlScriptsConfiguration { @@ -244,7 +211,7 @@ export class YamlWorkspace { } } -const supportedNodeRuntimes: string[] = ["nodejs16.x", "nodejs18.x"]; +const supportedNodeRuntimes = ["nodejs16.x", "nodejs18.x"] as const; export enum YamlProjectConfigurationType { FRONTEND, @@ -309,136 +276,180 @@ export class YamlProjectConfiguration { return classConfiguration; } - static async create(configurationFileContent: any): Promise { - if (!configurationFileContent.name) { - throw new Error("The name property is missing from the configuration file."); - } - - const nameRegex = new RegExp("^[a-zA-Z][-a-zA-Z0-9]*$"); - if (!nameRegex.test(configurationFileContent.name)) { - throw new Error("The project name is not valid. It must be [a-zA-Z][-a-zA-Z0-9]*"); - } - - let sdk: YamlSdkConfiguration | undefined; - let classes: YamlClassConfiguration[] = []; - if ( - configurationFileContent.options && - configurationFileContent.options.nodeRuntime && - !supportedNodeRuntimes.includes(configurationFileContent.options.nodeRuntime) - ) { - throw new Error( - "The node version in the genezio.yaml configuration file is not valid. The value must be one of the following: " + - supportedNodeRuntimes.join(", "), - ); - } - - const projectLanguage = configurationFileContent.language - ? Language[configurationFileContent.language as keyof typeof Language] - : Language.ts; - - if ( - configurationFileContent.sdk && - configurationFileContent.sdk.path && - configurationFileContent.sdk.language - ) { - const language: string = configurationFileContent.sdk.language; - - if (!Language[language as keyof typeof Language]) { - log.info( - "This sdk.language is not supported by default. It will be treated as a custom language.", - ); - } - - sdk = new YamlSdkConfiguration( - Language[configurationFileContent.sdk.language as keyof typeof Language], - configurationFileContent.sdk.path, - ); - } - - const unparsedClasses: any[] = configurationFileContent.classes; - - // check if unparsedClasses is an array - if (unparsedClasses && !Array.isArray(unparsedClasses)) { - throw new Error("The classes property must be an array."); - } - - if (unparsedClasses && Array.isArray(unparsedClasses)) { - classes = await Promise.all( - unparsedClasses.map((c) => YamlClassConfiguration.create(c)), - ); - } - - if ( - configurationFileContent.plugins?.astGenerator && - !Array.isArray(configurationFileContent.plugins?.astGenerator) - ) { - throw new Error("astGenerator must be an array"); - } - if ( - configurationFileContent.plugins?.sdkGenerator && - !Array.isArray(configurationFileContent.plugins?.sdkGenerator) - ) { - throw new Error("sdkGenerator must be an array"); - } - - const plugins: YamlPluginsConfiguration | undefined = configurationFileContent.plugins; - - if (configurationFileContent.cloudProvider) { - if (!cloudProviders.includes(configurationFileContent.cloudProvider)) { - throw new Error( - `The cloud provider ${configurationFileContent.cloudProvider} is invalid. Please use ${CloudProviderIdentifier.GENEZIO} or ${CloudProviderIdentifier.SELF_HOSTED_AWS}.`, - ); - } - } - - const scripts: YamlScriptsConfiguration | undefined = configurationFileContent.scripts; + static async create( + configurationFileContent: RawYamlConfiguration, + ): Promise { + const methodConfigurationSchema = zod + .object({ + name: zod.string(), + type: zod.nativeEnum(TriggerType).optional(), + cronString: zod.string().optional(), + }) + .refine(({ type, cronString }) => { + if (type === TriggerType.cron && cronString === undefined) return false; + + return true; + }, "Cron methods must have a cronString property.") + .refine(({ type, cronString }) => { + if (type === TriggerType.cron && cronString && !isValidCron(cronString)) { + return false; + } + + return true; + }, "The cronString is not valid. Check https://crontab.guru/ for more information.") + .refine(({ type, cronString }) => { + const cronParts = cronString?.split(" "); + if ( + type === TriggerType.cron && + cronParts && + cronParts[2] != "*" && + cronParts[4] != "*" + ) { + return false; + } + + return true; + }, "The day of the month and day of the week cannot be specified at the same time."); + + const configurationFileSchema = zod.object({ + name: zod.string().refine((value) => { + const nameRegex = new RegExp("^[a-zA-Z][-a-zA-Z0-9]*$"); + return nameRegex.test(value); + }, "Must start with a letter and contain only letters, numbers and dashes."), + region: zod.enum(regions).default("us-east-1"), + language: zod.nativeEnum(Language).default(Language.ts), + cloudProvider: zod + .nativeEnum(CloudProviderIdentifier, { + errorMap: (issue, ctx) => { + if (issue.code === zod.ZodIssueCode.invalid_enum_value) { + return { + message: + "Invalid enum value. The supported values are `genezio` or `selfHostedAws`.", + }; + } + + return { message: ctx.defaultError }; + }, + }) + .default(CloudProviderIdentifier.GENEZIO), + classes: zod + .array( + zod + .object({ + path: zod.string(), + type: zod.nativeEnum(TriggerType).default(TriggerType.jsonrpc), + name: zod.string().optional(), + methods: zod.array(methodConfigurationSchema).optional(), + }) + // Hack to make sure that the method type is set to the class type + .transform((value) => { + for (const method of value.methods || []) { + method.type = method.type || value.type; + } + + return value; + }), + ) + .optional(), + options: zod + .object({ + nodeRuntime: zod.enum(supportedNodeRuntimes).default(DEFAULT_NODE_RUNTIME), + }) + .optional(), + sdk: zod + .object({ + language: zod.nativeEnum(Language), + path: zod.string(), + }) + .optional(), + frontend: zod + .object({ + path: zod.string(), + subdomain: zod + .string() + .optional() + .refine((value) => { + if (!value) return true; + + const subdomainRegex = new RegExp("^[a-zA-Z0-9-]+$"); + return subdomainRegex.test(value); + }, "A valid subdomain only contains letters, numbers and dashes."), + }) + .optional(), + workspace: zod + .object({ + backend: zod.string(), + frontend: zod.string(), + }) + .optional(), + packageManager: zod.nativeEnum(PackageManagerType).default(PackageManagerType.npm), + scripts: zod + .object({ + preBackendDeploy: zod.string().optional(), + postBackendDeploy: zod.string().optional(), + postFrontendDeploy: zod.string().optional(), + preFrontendDeploy: zod.string().optional(), + preStartLocal: zod.string().optional(), + postStartLocal: zod.string().optional(), + preReloadLocal: zod.string().optional(), + }) + .optional(), + plugins: zod + .object({ + astGenerator: zod.array(zod.string()), + sdkGenerator: zod.array(zod.string()), + }) + .optional(), + }); - if (configurationFileContent.region) { - if (!regions.includes(configurationFileContent.region)) { + let configurationFile; + try { + configurationFile = configurationFileSchema.parse(configurationFileContent); + } catch (e) { + if (e instanceof zod.ZodError) { throw new Error( - `The region is invalid. Please use a valid region.\n Region list: ${regions}`, + `There was a problem parsing your YAML configuration!\n${zodFormatError(e)}`, ); } + throw new Error(`There was a problem parsing your YAML configuration!\n${e}`); } - if (configurationFileContent.frontend) { - if (!configurationFileContent.frontend.path) { - throw new Error("The frontend.path value is not set."); - } - } - - let workspace; - if (configurationFileContent.workspace) { - if (!configurationFileContent.language) { - throw new Error('"language" property is missing from genezio.yaml.'); - } - - if (!configurationFileContent.workspace.frontend) { - throw new Error('"frontend" property is missing from workspace in genezio.yaml.'); - } - - if (!configurationFileContent.workspace.backend) { - throw new Error('"backend" property is missing from workspace in genezio.yaml.'); - } - workspace = new YamlWorkspace( - configurationFileContent.workspace.backend, - configurationFileContent.workspace.frontend, + const unparsedClasses = configurationFile.classes || []; + const classes = unparsedClasses.map((classConfiguration) => { + const methods = classConfiguration.methods || []; + + return new YamlClassConfiguration( + classConfiguration.path, + classConfiguration.type, + path.parse(classConfiguration.path).ext, + methods.map( + (method) => + new YamlMethodConfiguration(method.name, method.type, method.cronString), + ), + classConfiguration.name, ); - } + }); + + const workspace = configurationFile.workspace + ? new YamlWorkspace( + configurationFile.workspace.backend, + configurationFile.workspace.frontend, + ) + : undefined; return new YamlProjectConfiguration( - configurationFileContent.name, - configurationFileContent.region || "us-east-1", - projectLanguage, - sdk, - configurationFileContent.cloudProvider || CloudProviderIdentifier.GENEZIO, + configurationFile.name, + configurationFile.region, + configurationFile.language, + configurationFile.sdk, + configurationFile.cloudProvider, classes, - configurationFileContent.frontend, - scripts, - plugins, - configurationFileContent.options, + configurationFile.frontend, + configurationFile.scripts, + configurationFile.plugins, + configurationFile.options, workspace, - configurationFileContent.packageManager, + configurationFile.packageManager, ); } diff --git a/src/utils/configs.ts b/src/utils/configs.ts index 0041596af..4109e5d22 100644 --- a/src/utils/configs.ts +++ b/src/utils/configs.ts @@ -34,7 +34,7 @@ export const regions = [ "eu-west-3", "eu-north-1", "sa-east-1", -]; +] as const; export const regionNames = [ "US East (N. Virginia)", diff --git a/tests/projectConfiguration.test.ts b/tests/projectConfiguration.test.ts index b35cf51ad..24d36ee01 100644 --- a/tests/projectConfiguration.test.ts +++ b/tests/projectConfiguration.test.ts @@ -7,7 +7,7 @@ describe("project configuration", () => { await expect(async () => { const yaml = {}; await YamlProjectConfiguration.create(yaml); - }).rejects.toThrowError("The name property is missing from the configuration file."); + }).rejects.toThrowError("Field `name`:\n\t- Required"); }); test("invalid region should throw error", async () => { @@ -29,13 +29,12 @@ describe("project configuration", () => { ], }; await YamlProjectConfiguration.create(yaml); - }).rejects.toThrowError("The region is invalid. Please use a valid region."); + }).rejects.toThrowError("Field `region`:\n\t- Invalid enum value."); }); test("missing region should assign default to us-east-1", async () => { const yaml = { name: "test", - region: "", sdk: { path: "/", language: "js",