From fca6481177176fa5a487b5b3c0137c850df08d77 Mon Sep 17 00:00:00 2001 From: Arnaud Laval <9974486+Relixik@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:07:41 +0100 Subject: [PATCH] feat: add custom protoc-gen-zod plugin with buf.validate support Add a custom Buf plugin that generates Zod schemas from proto definitions with automatic validation rules from buf.validate annotations. Features: - Converts buf.validate constraints to Zod validations (.min(), .startsWith(), etc.) - Generates enum helper maps (ENUM_MAP and STRING_TO_ENUM) - Supports all scalar types, well-known types, and wrapper types - Handles recursive message types with z.lazy() - Skips empty files (no messages/enums) - Uses z.enum() instead of deprecated z.nativeEnum() for Zod v4 New files: - tools/zod/ - Custom protoc-gen-zod plugin source - .npmrc - Buf registry config for @buf/* dependencies fix: change module type from ESNext to CommonJS in package.json and tsconfig.gen.json feat(validation): enhance item-level constraints for repeated fields in Zod schemas feat(validation): add support for int64/uint64 constraints in Zod validation chains feat(validation): update file_id validation to use pattern matching for file identifiers feat(validation): enhance validation handling for optional fields and string patterns in Zod schemas fix(filesystem): regex in fileFilter prefix fix(filesystem): allow 'default' as valid context in UploadFileData Update the context field validation pattern to accept 'default' as a valid value in addition to the existing missions: and setups: prefixes. chore: update devDependencies in package.json - Bump @bufbuild/buf from 1.61.0 to 1.64.0 - Update @types/node from 25.0.3 to 25.2.0 fix(filesystem): enhance regex for prefix validation in FileFilter and UploadFileData Updated the regex pattern for the 'prefix' field in FileFilter to prevent path traversal and ensure valid path formats. Additionally, added a pattern validation for the 'name' field in UploadFileData to enforce similar constraints. fix(dependencies): update grpc-js, zod, and bufbuild packages to latest versions --- .gitignore | 3 + .npmrc | 1 + buf.gen.ts.yaml | 19 +- package.json | 44 +- .../filesystem/v1/filesystem.proto | 20 +- tools/zod/src/generator.ts | 533 +++++++++++++++++ tools/zod/src/index.ts | 36 ++ tools/zod/src/type-mapper.ts | 302 ++++++++++ tools/zod/src/utils.ts | 128 +++++ tools/zod/src/validation-mapper.ts | 543 ++++++++++++++++++ tools/zod/tsconfig.json | 20 + tsconfig.gen.json | 2 +- 12 files changed, 1617 insertions(+), 34 deletions(-) create mode 100644 .npmrc create mode 100644 tools/zod/src/generator.ts create mode 100644 tools/zod/src/index.ts create mode 100644 tools/zod/src/type-mapper.ts create mode 100644 tools/zod/src/utils.ts create mode 100644 tools/zod/src/validation-mapper.ts create mode 100644 tools/zod/tsconfig.json diff --git a/.gitignore b/.gitignore index 0a85332..2323953 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ yarn.lock run.sh *.tmp *.log + +# Dist folders in tools +/tools/**/dist/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..8dd477a --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@buf:registry=https://buf.build/gen/npm/v1 diff --git a/buf.gen.ts.yaml b/buf.gen.ts.yaml index 085d6ae..19ad7ad 100644 --- a/buf.gen.ts.yaml +++ b/buf.gen.ts.yaml @@ -24,11 +24,10 @@ plugins: - outputClientImpl=grpc-js # Generate client implementations - env=node # Node.js environment - esModuleInterop=true # Better import compatibility - + # Index generation - - outputIndex=true # Generate index.ts files - exportCommonSymbols=false # Don't re-export common protobuf symbols - + # Type safety - useOptionals=messages # Optional fields as T | undefined - useDate=true # Use Date instead of Timestamp @@ -42,3 +41,17 @@ plugins: # Code style - esLintDisable=true # Add eslint-disable comments + + # ========================================== + # Zod Schema Generation (with buf.validate support) + # ========================================== + + # protoc-gen-zod generates Zod schemas from proto definitions + # Reads buf.validate annotations and converts them to Zod validations + - local: + - node + - ./tools/zod/dist/index.js + out: gen/typescript + opt: + - target=ts + - include_responses=true diff --git a/package.json b/package.json index c7e0644..fa4e9d9 100644 --- a/package.json +++ b/package.json @@ -2,27 +2,26 @@ "name": "@digitalkin/agentic-mesh-protocol", "version": "1.0.0", "description": "Node.js gRPC interfaces for Agentic Mesh Protocol - Protocol Buffer definitions for multi-agent systems", - "type": "module", + "type": "commonjs", "main": "index.ts", "types": "index.ts", "files": [ - "index.ts", - "gen/typescript/**/*.ts", - "gen/typescript/**/*.js", - "gen/typescript/**/*.d.ts", - "gen/descriptor.bin", - "gen/descriptor.json", - "proto/**/*.proto", - "buf.yaml", - "buf.gen.ts.yaml" + "gen/typescript/**/*.ts", + "gen/typescript/**/*.js", + "gen/typescript/**/*.d.ts", + "gen/descriptor.bin", + "gen/descriptor.json", + "proto/**/*.proto", + "buf.gen.ts.yaml" ], "scripts": { "deps:update": "npx buf dep update proto", - "generate": "npx buf generate --template buf.gen.ts.yaml && tsc -p tsconfig.gen.json", - "generate:reflection": "npx buf build proto -o gen/descriptor.bin && npx buf build proto -o gen/descriptor.json", + "build:zod-plugin": "tsc --project ./tools/zod/tsconfig.json", + "generate": "npm run build:zod-plugin && npx buf generate --template buf.gen.ts.yaml && tsc -p tsconfig.gen.json", + "generate:reflection": "npx buf build proto -o gen/descriptor.bin && npx buf build proto -o gen/descriptor.json", "build": "npm run generate", "prepare": "npm run generate && npm run generate:reflection", - "clean": "rm -rf gen/", + "clean": "rm -rf gen/ && rm -rf tools/zod/dist/", "prepublishOnly": "npm run build" }, "keywords": [ @@ -45,18 +44,23 @@ }, "homepage": "https://github.com/DigitalKin-ai/agentic-mesh-protocol#readme", "dependencies": { - "@grpc/grpc-js": "^1.14.2", - "google-protobuf": "^4.0.1" + "@grpc/grpc-js": "^1.14.3", + "google-protobuf": "^4.0.1", + "zod": "^4.3.6" }, "peerDependencies": { - "@bufbuild/protobuf": "^2.0.0" + "@bufbuild/protobuf": "^2.11.0", + "zod": "^4.3.6" }, "devDependencies": { - "@bufbuild/buf": "1.61.0", + "@buf/bufbuild_protovalidate.bufbuild_es": "^2.11.0-20251209175733-2a1774d88802.1", + "@bufbuild/buf": "1.65.0", + "@bufbuild/protobuf": "^2.11.0", + "@bufbuild/protoplugin": "^2.11.0", "@types/google-protobuf": "^3.15.12", - "@types/node": "^24.10.1", - "ts-proto": "^2.8.3", - "typescript": "^5.8.0" + "@types/node": "^25.2.0", + "ts-proto": "^2.11.2", + "typescript": "^5.9.3" }, "engines": { "node": ">=18.0.0" diff --git a/proto/agentic_mesh_protocol/filesystem/v1/filesystem.proto b/proto/agentic_mesh_protocol/filesystem/v1/filesystem.proto index e929769..1960bff 100644 --- a/proto/agentic_mesh_protocol/filesystem/v1/filesystem.proto +++ b/proto/agentic_mesh_protocol/filesystem/v1/filesystem.proto @@ -58,10 +58,10 @@ enum FileStatus { // File represents a stored file with comprehensive metadata. message File { - // file_id: Unique identifier for the file (UUID) + // file_id: Unique identifier for the file string file_id = 1 [ (buf.validate.field).required = true, - (buf.validate.field).string.uuid = true + (buf.validate.field).string.pattern = "^files:.*$" ]; // context: Context ID linked to the file @@ -131,7 +131,7 @@ message FileFilter { repeated string names = 1; // file_ids: Filter by file IDs - repeated string file_ids = 2 [(buf.validate.field).repeated.items.string.uuid = true]; + repeated string file_ids = 2 [(buf.validate.field).repeated.items.string.pattern = "^files:.*$"]; // file_types: Filter by file types repeated FileType file_types = 3 [(buf.validate.field).repeated.items.enum.not_in = 0]; @@ -166,8 +166,8 @@ message FileFilter { // max_size_bytes: Filter files with maximum size int64 max_size_bytes = 12 [(buf.validate.field).int64.gte = 0]; - // prefix: Filter by path prefix (e.g., "folder1/") - string prefix = 13 [(buf.validate.field).string.pattern = "^[^/]*/?[^/]*$"]; + // prefix: Filter by path prefix (e.g., "/folder1/"). Prevents path traversal (no ".." allowed). + string prefix = 13 [(buf.validate.field).string.pattern = "^[^.]*([.][^.]+)*[.]?$"]; // content_type: Filter by content type string content_type = 14; @@ -189,13 +189,14 @@ message UploadFileData { // context: Context ID for the file string context = 1 [ (buf.validate.field).required = true, - (buf.validate.field).string.pattern = "^(missions:|setups:).*$" + (buf.validate.field).string.pattern = "(^(missions:|setups:).*$|^default$)" ]; // name: Name of the file string name = 2 [ (buf.validate.field).required = true, - (buf.validate.field).string.min_len = 1 + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.pattern = "^[^.]*([.][^.]+)*[.]?$" ]; // file_type: Type classification of the file @@ -254,14 +255,13 @@ message UploadFilesResponse { message GetFileRequest { // context: Context ID for the file string context = 1 [ - (buf.validate.field).required = true, (buf.validate.field).string.pattern = "^(missions:|setups:).*$" ]; // file_id: File ID string file_id = 2 [ (buf.validate.field).required = true, - (buf.validate.field).string.uuid = true + (buf.validate.field).string.pattern = "^files:.*$" ]; // include_content: Whether to include file content in response @@ -288,7 +288,7 @@ message UpdateFileRequest { // file_id: Current id of the file string file_id = 2 [ (buf.validate.field).required = true, - (buf.validate.field).string.uuid = true + (buf.validate.field).string.pattern = "^files:.*$" ]; // new_name: New name for the file (optional) diff --git a/tools/zod/src/generator.ts b/tools/zod/src/generator.ts new file mode 100644 index 0000000..160c91e --- /dev/null +++ b/tools/zod/src/generator.ts @@ -0,0 +1,533 @@ +/** + * Main generator that produces Zod schema files from proto files + */ + +import type { Schema, GeneratedFile } from "@bufbuild/protoplugin"; +import type { DescFile, DescMessage, DescEnum, DescField } from "@bufbuild/protobuf"; +import { + mapFieldToZod, + isFieldOptional, + type TypeMapperContext, +} from "./type-mapper.js"; +import { getValidationChain, isFieldRequired } from "./validation-mapper.js"; +import { toCamelCase, toSchemaName, getRelativeImportPath, toScreamingSnakeCase, stripEnumPrefix } from "./utils.js"; + +export interface PluginOptions { + /** Whether to include Response messages (usually not needed for form validation) */ + includeResponses: boolean; +} + +/** + * Main entry point for generating Zod schemas + */ +export function generateZodSchemas( + schema: Schema +): void { + for (const file of schema.files) { + // Skip third-party dependencies - only generate for local proto files + if (file.name.startsWith("buf/") || file.name.startsWith("google/")) { + continue; + } + generateFileSchemas(schema, file); + } +} + +/** + * Generates Zod schemas for a single proto file + */ +function generateFileSchemas( + schema: Schema, + file: DescFile +): void { + // Filter messages first to check if we have anything to generate + const messages = file.messages.filter((msg) => { + // Skip Response messages unless option is set + if (!schema.options.includeResponses && msg.name.endsWith("Response")) { + return false; + } + return true; + }); + + const localEnums = file.enums; + + // Skip generating file if there are no messages or enums to export + if (messages.length === 0 && localEnums.length === 0) { + return; + } + + // Preserve directory structure: mirai/v1/auth -> mirai/v1/auth_zod.ts + // Note: file.name excludes the .proto suffix + const outputFileName = `${file.name}_zod.ts`; + + const f = schema.generateFile(outputFileName); + + // Add file header + f.print(`// @generated from file ${file.name}.proto`); + f.print("/* eslint-disable */"); + f.print(); + + // Collect all imports needed + const imports = new Map>(); + const context: TypeMapperContext = { + currentProtoPath: file.name, + }; + + // Collect imports from all messages + for (const message of messages) { + collectMessageImports(message, context, imports); + } + + // Collect enum imports - local enums need to be imported for z.enum to work + // ts-proto generates files with .js suffix for ES modules + const pbImportPath = getRelativeImportPath(file.name, file.name, ".js"); + for (const enumDesc of localEnums) { + const existing = imports.get(pbImportPath) ?? new Set(); + existing.add(enumDesc.name); + imports.set(pbImportPath, existing); + } + + // Write zod import + f.print('import { z } from "zod";'); + + // Write imports from *_pb.ts files (for enums) + for (const [importPath, names] of imports) { + const sortedNames = Array.from(names).sort(); + f.print(`import { ${sortedNames.join(", ")} } from "${importPath}";`); + } + + f.print(); + + // Generate enum schemas for enums defined in this file + for (const enumDesc of localEnums) { + generateEnumSchema(f, enumDesc); + } + + // Generate message schemas + // Sort by dependency order and identify recursive types + const { sortedMessages, recursiveTypes } = analyzeMessageDependencies(messages, file.name); + + for (const message of sortedMessages) { + generateMessageSchema(f, message, context, recursiveTypes); + f.print(); + } +} + +/** + * Collects all imports needed for a message's fields + */ +function collectMessageImports( + message: DescMessage, + context: TypeMapperContext, + imports: Map> +): void { + for (const field of message.fields) { + const typeInfo = mapFieldToZod(field, context); + if (typeInfo.needsImport) { + const existing = imports.get(typeInfo.needsImport.from) ?? new Set(); + existing.add(typeInfo.needsImport.name); + imports.set(typeInfo.needsImport.from, existing); + } + } + + // Recurse into nested messages + for (const nested of message.nestedMessages) { + collectMessageImports(nested, context, imports); + } +} + +/** + * Generates a Zod schema for an enum + */ +function generateEnumSchema( + f: GeneratedFile, + enumDesc: DescEnum +): void { + const enumName = enumDesc.name; + const schemaName = toSchemaName(enumName); + const screaming = toScreamingSnakeCase(enumName); + + // Add JSDoc + f.print("/**"); + f.print(` * Zod schema for ${enumName} enum`); + if (enumDesc.deprecated) { + f.print(" * @deprecated"); + } + f.print(` * @generated from enum ${enumDesc.typeName}`); + f.print(" */"); + + // Enum is already imported at the top of the file + f.print(`export const ${schemaName} = z.enum(${enumName});`); + f.print(`export type ${enumName}Type = z.infer;`); + f.print(); + + // Generate number to string map + f.print("/**"); + f.print(` * Map of ${enumName} enum values to string representations`); + f.print(` * @generated from enum ${enumDesc.typeName}`); + f.print(" */"); + f.print(`export const ${screaming}_MAP: Record = {`); + for (const value of enumDesc.values) { + const strippedName = stripEnumPrefix(value.name, enumName); + f.print(` ${value.number}: "${strippedName}",`); + } + f.print("};"); + f.print(); + + // Generate string to enum map (excluding UNSPECIFIED) + f.print("/**"); + f.print(` * Map of string representations to ${enumName} enum values`); + f.print(` * @generated from enum ${enumDesc.typeName}`); + f.print(" */"); + f.print(`export const STRING_TO_${screaming}: Record = {`); + for (const value of enumDesc.values) { + // Skip UNSPECIFIED (value 0) for the reverse map + if (value.number === 0) continue; + const strippedName = stripEnumPrefix(value.name, enumName); + f.print(` ${strippedName}: ${enumName}.${value.name},`); + } + f.print("};"); + f.print(); +} + +/** + * Generates a Zod schema for a message + */ +function generateMessageSchema( + f: GeneratedFile, + message: DescMessage, + context: TypeMapperContext, + recursiveTypes: Set +): void { + const schemaName = toSchemaName(message.name); + const isRecursive = recursiveTypes.has(message.name); + + // Add JSDoc + f.print("/**"); + f.print(` * Zod schema for ${message.name}`); + if (message.deprecated) { + f.print(" * @deprecated"); + } + f.print(` * @generated from message ${message.typeName}`); + f.print(" */"); + + if (isRecursive) { + // For recursive types, we need to use z.lazy() with explicit type annotation + f.print(`export type ${message.name} = {`); + for (const field of message.fields) { + const fieldName = toCamelCase(field.name); + const fieldIsOptional = isFieldOptional(field); + const validation = getValidationChain(field); + const fieldIsRequired = isFieldRequired(field) || validation.required; + const optional = fieldIsOptional && !fieldIsRequired ? "?" : ""; + + // Generate TypeScript type based on field kind + const tsType = inferTsType(field); + f.print(` ${fieldName}${optional}: ${tsType};`); + } + f.print("};"); + f.print(); + + // Generate the schema using z.lazy for the base + f.print(`export const ${schemaName}: z.ZodType<${message.name}> = z.lazy(() => z.object({`); + for (const field of message.fields) { + generateFieldSchema(f, field, context, recursiveTypes); + } + f.print("}));"); + } else { + // Start schema definition + f.print(`export const ${schemaName} = z.object({`); + + for (const field of message.fields) { + generateFieldSchema(f, field, context, recursiveTypes); + } + + f.print("});"); + + // Export inferred type + f.print(); + f.print(`export type ${message.name} = z.infer;`); + } +} + +/** + * Infers the TypeScript type for a field (used for recursive type declarations) + */ +function inferTsType(field: DescField): string { + const wrapArray = (inner: string) => field.fieldKind === "list" ? `${inner}[]` : inner; + + if (field.fieldKind === "scalar" || (field.fieldKind === "list" && field.listKind === "scalar")) { + const scalarKind = field.fieldKind === "scalar" ? field.scalar : field.scalar; + switch (scalarKind) { + case 9: return wrapArray("string"); // STRING + case 8: return wrapArray("boolean"); // BOOL + case 12: return wrapArray("Uint8Array"); // BYTES + default: return wrapArray("number"); // All numeric types + } + } + + if (field.fieldKind === "enum" || (field.fieldKind === "list" && field.listKind === "enum")) { + const enumName = field.enum.name; + return wrapArray(enumName); + } + + if (field.fieldKind === "message" || (field.fieldKind === "list" && field.listKind === "message")) { + const msgName = field.message.name; + const msgTypeName = field.message.typeName; + + // Handle well-known types + if (msgTypeName === "google.protobuf.Timestamp") { + return wrapArray("string"); // Timestamps are serialized as ISO strings + } + + return wrapArray(msgName); + } + + if (field.fieldKind === "map") { + return "Record"; + } + + return "unknown"; +} + +/** + * Escapes a regex pattern for use in generated code + */ +function escapePattern(pattern: string): string { + return pattern.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** + * Generates a Zod schema for a single field + */ +function generateFieldSchema( + f: GeneratedFile, + field: DescField, + context: TypeMapperContext, + recursiveTypes: Set +): void { + const fieldName = toCamelCase(field.name); + const typeInfo = mapFieldToZod(field, context); + const validation = getValidationChain(field); + + // Determine if field is required early (needed for pattern handling) + const fieldIsOptional = isFieldOptional(field); + const fieldIsRequired = isFieldRequired(field) || validation.required; + + // Build the Zod type with validation chain + let zodExpression = typeInfo.zodType; + + // Check if this field references a recursive type (including self-reference) + if (field.fieldKind === "message" || (field.fieldKind === "list" && field.listKind === "message")) { + const refMsgName = field.message.name; + if (recursiveTypes.has(refMsgName)) { + // Use z.lazy() for references to recursive types + const schemaRef = toSchemaName(refMsgName); + if (field.fieldKind === "list") { + zodExpression = `z.array(z.lazy(() => ${schemaRef}))`; + } else { + zodExpression = `z.lazy(() => ${schemaRef})`; + } + } + } + + // For list fields, apply item-level constraints from buf.validate + if (field.fieldKind === "list" && (validation.itemMethods.length > 0 || validation.itemEnumNotIn.length > 0 || validation.itemStringPattern)) { + // Extract the item type from z.array(itemType) + const match = zodExpression.match(/^z\.array\((.+)\)$/); + if (match) { + let itemType = match[1]; + + // Apply item methods (e.g., .uuid(), .min(), .max()) + for (const method of validation.itemMethods) { + itemType += method; + } + + // Apply item string pattern - items in arrays should validate if non-empty + // (empty strings in arrays are typically not valid items) + if (validation.itemStringPattern) { + const escapedPattern = escapePattern(validation.itemStringPattern); + itemType += `.refine((v) => v === "" || new RegExp("${escapedPattern}").test(v), { message: "Must match pattern: ${escapedPattern}" })`; + } + + // Apply enum notIn constraint for items + if (validation.itemEnumNotIn.length > 0) { + const values = validation.itemEnumNotIn.join(", "); + itemType += `.refine((e) => ![${values}].includes(e), { message: "Must not be one of: ${values}" })`; + } + + zodExpression = `z.array(${itemType})`; + } + } + + // Add validation methods from buf.validate annotations + for (const method of validation.methods) { + zodExpression += method; + } + + // Handle string pattern constraint + // If required: use strict .regex() + // If optional: use .refine() that allows empty strings (Proto3 default value) + if (validation.stringPattern) { + const escapedPattern = escapePattern(validation.stringPattern); + if (fieldIsRequired) { + zodExpression += `.regex(new RegExp("${escapedPattern}"))`; + } else { + zodExpression += `.refine((v) => v === "" || new RegExp("${escapedPattern}").test(v), { message: "Must match pattern: ${escapedPattern}" })`; + } + } + + // Handle enum defined_only constraint + if (validation.enumDefinedOnly && field.fieldKind === "enum") { + zodExpression += `.refine((v) => v !== 0, "Value is required")`; + } + + // Handle optional fields + if (fieldIsOptional && !fieldIsRequired) { + zodExpression += ".optional()"; + } + + // Add field comment if deprecated + if (field.deprecated) { + f.print(` /** @deprecated */`); + } + + f.print(` ${fieldName}: ${zodExpression},`); +} + +/** + * Analyzes message dependencies and returns sorted list plus recursive types + */ +interface DependencyAnalysis { + sortedMessages: DescMessage[]; + recursiveTypes: Set; +} + +function analyzeMessageDependencies( + messages: readonly DescMessage[], + currentProtoPath: string +): DependencyAnalysis { + const messageNames = new Set(messages.map((m) => m.name)); + // Map: message name -> messages it depends on (references) + const dependencies = new Map>(); + const recursiveTypes = new Set(); + + // Initialize dependencies + for (const msg of messages) { + dependencies.set(msg.name, new Set()); + } + + // Build dependency graph (for messages in the same file) + for (const msg of messages) { + for (const field of msg.fields) { + if (field.fieldKind === "message") { + const refMsgName = field.message.name; + if (messageNames.has(refMsgName) && field.message.file.name === currentProtoPath) { + // msg depends on refMsgName (msg references refMsgName) + dependencies.get(msg.name)!.add(refMsgName); + + // Check for self-reference (recursive type) + if (refMsgName === msg.name) { + recursiveTypes.add(msg.name); + } + } + } else if (field.fieldKind === "list" && field.listKind === "message") { + const refMsgName = field.message.name; + if (messageNames.has(refMsgName) && field.message.file.name === currentProtoPath) { + dependencies.get(msg.name)!.add(refMsgName); + + if (refMsgName === msg.name) { + recursiveTypes.add(msg.name); + } + } + } + } + } + + // Detect cycles (multi-step recursion) + for (const msgName of messageNames) { + if (hasCycle(msgName, dependencies, new Set())) { + recursiveTypes.add(msgName); + } + } + + // Kahn's algorithm - remove self-loops for sorting purposes + const inDegree = new Map(); + for (const msg of messages) { + inDegree.set(msg.name, 0); + } + + for (const [msgName, deps] of dependencies) { + for (const dep of deps) { + if (dep !== msgName && !recursiveTypes.has(dep)) { + // Don't count self-references or references to recursive types + inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1); + } + } + } + + // Start with messages that are not referenced by others (leaves) + // Actually we need the opposite - start with messages that don't depend on others + const result: DescMessage[] = []; + const visited = new Set(); + const queue: string[] = []; + + // Find messages with no dependencies (except self-refs and recursive) + for (const msg of messages) { + const deps = dependencies.get(msg.name)!; + const nonSelfDeps = Array.from(deps).filter(d => d !== msg.name && !recursiveTypes.has(d)); + if (nonSelfDeps.length === 0) { + queue.push(msg.name); + } + } + + while (queue.length > 0) { + const name = queue.shift()!; + if (visited.has(name)) continue; + visited.add(name); + + const msg = messages.find((m) => m.name === name)!; + result.push(msg); + + // Find messages that depend on this one and now have all deps satisfied + for (const [otherName, deps] of dependencies) { + if (visited.has(otherName)) continue; + if (deps.has(name)) { + // Check if all non-recursive deps are now satisfied + const nonSelfDeps = Array.from(deps).filter(d => d !== otherName && !recursiveTypes.has(d)); + if (nonSelfDeps.every(d => visited.has(d))) { + queue.push(otherName); + } + } + } + } + + // Append any remaining (circular dependencies handled by z.lazy) + for (const msg of messages) { + if (!result.includes(msg)) { + result.push(msg); + } + } + + return { sortedMessages: result, recursiveTypes }; +} + +/** + * Checks if there's a cycle starting from the given node + */ +function hasCycle( + node: string, + dependencies: Map>, + visiting: Set +): boolean { + if (visiting.has(node)) return true; + visiting.add(node); + + for (const dep of dependencies.get(node) ?? []) { + if (hasCycle(dep, dependencies, visiting)) { + return true; + } + } + + visiting.delete(node); + return false; +} diff --git a/tools/zod/src/index.ts b/tools/zod/src/index.ts new file mode 100644 index 0000000..1b7cf2b --- /dev/null +++ b/tools/zod/src/index.ts @@ -0,0 +1,36 @@ +/** + * protoc-gen-zod - Buf plugin for generating Zod schemas from Protocol Buffers + * + * This plugin generates TypeScript files containing Zod schemas that mirror + * the structure of Protocol Buffer messages, with validation rules derived + * from buf.validate annotations. + */ + +import { createEcmaScriptPlugin, runNodeJs } from "@bufbuild/protoplugin"; +import { generateZodSchemas, type PluginOptions } from "./generator.js"; + +/** + * Plugin definition + */ +const protocGenZod = createEcmaScriptPlugin({ + name: "protoc-gen-zod", + version: "v0.1.0", + generateTs: generateZodSchemas, + parseOptions(rawOptions): PluginOptions { + const options: PluginOptions = { + includeResponses: false, + }; + + for (const opt of rawOptions) { + if (opt.key === "include_responses" && opt.value === "true") { + options.includeResponses = true; + } + // Ignore target option (handled by protoplugin) + } + + return options; + }, +}); + +// Run the plugin +runNodeJs(protocGenZod); diff --git a/tools/zod/src/type-mapper.ts b/tools/zod/src/type-mapper.ts new file mode 100644 index 0000000..bdadaba --- /dev/null +++ b/tools/zod/src/type-mapper.ts @@ -0,0 +1,302 @@ +/** + * Maps Protocol Buffer types to Zod schema types + */ + +import type { DescField, DescEnum, DescMessage } from "@bufbuild/protobuf"; +import { ScalarType } from "@bufbuild/protobuf"; +import { getRelativeImportPath, toSchemaName } from "./utils.js"; + +export interface ZodTypeInfo { + /** The Zod type expression, e.g., "z.string()", "z.number().int()" */ + zodType: string; + /** Import needed from another file (for enums or nested messages) */ + needsImport?: { + name: string; + from: string; + isType?: boolean; + }; + /** Whether this is a nested message reference */ + isNestedMessage?: boolean; +} + +export interface TypeMapperContext { + /** The proto file path we're generating from */ + currentProtoPath: string; +} + +/** + * Maps a proto field to its Zod type representation + */ +export function mapFieldToZod( + field: DescField, + context: TypeMapperContext +): ZodTypeInfo { + // Handle map fields first + if (field.fieldKind === "map") { + return mapMapFieldToZod(field, context); + } + + // Handle list fields (repeated) + if (field.fieldKind === "list") { + const itemType = mapListItemToZod(field, context); + return { + zodType: `z.array(${itemType.zodType})`, + needsImport: itemType.needsImport, + }; + } + + return mapSingleFieldToZod(field, context); +} + +/** + * Maps a list (repeated) field item to Zod + */ +function mapListItemToZod( + field: DescField & { fieldKind: "list" }, + context: TypeMapperContext +): ZodTypeInfo { + if (field.listKind === "scalar") { + return { zodType: mapScalarToZod(field.scalar) }; + } else if (field.listKind === "enum") { + return mapEnumToZod(field.enum, context); + } else if (field.listKind === "message") { + return mapMessageToZod(field.message, context); + } + return { zodType: "z.unknown()" }; +} + +function mapMapFieldToZod( + field: DescField & { fieldKind: "map" }, + context: TypeMapperContext +): ZodTypeInfo { + const keyType = mapScalarToZod(field.mapKey); + + // Map value can be scalar, enum, or message + let valueType: ZodTypeInfo; + if (field.mapKind === "scalar") { + valueType = { zodType: mapScalarToZod(field.scalar) }; + } else if (field.mapKind === "enum") { + valueType = mapEnumToZod(field.enum, context); + } else if (field.mapKind === "message") { + valueType = mapMessageToZod(field.message, context); + } else { + valueType = { zodType: "z.unknown()" }; + } + + return { + zodType: `z.record(${keyType}, ${valueType.zodType})`, + needsImport: valueType.needsImport, + }; +} + +function mapSingleFieldToZod( + field: DescField, + context: TypeMapperContext +): ZodTypeInfo { + switch (field.fieldKind) { + case "scalar": + return { zodType: mapScalarToZod(field.scalar) }; + + case "enum": + return mapEnumToZod(field.enum, context); + + case "message": + return mapMessageToZod(field.message, context); + + default: + return { zodType: "z.unknown()" }; + } +} + +/** + * Maps a scalar proto type to Zod + */ +export function mapScalarToZod(scalar: ScalarType): string { + switch (scalar) { + case ScalarType.STRING: + return "z.string()"; + + case ScalarType.BOOL: + return "z.boolean()"; + + case ScalarType.INT32: + case ScalarType.SINT32: + case ScalarType.SFIXED32: + return "z.number().int()"; + + case ScalarType.UINT32: + case ScalarType.FIXED32: + return "z.number().int().nonnegative()"; + + case ScalarType.INT64: + case ScalarType.SINT64: + case ScalarType.SFIXED64: + // ts-proto with forceLong=string converts int64 to string + return "z.string()"; + + case ScalarType.UINT64: + case ScalarType.FIXED64: + // ts-proto with forceLong=string converts uint64 to string + return "z.string()"; + + case ScalarType.FLOAT: + case ScalarType.DOUBLE: + return "z.number()"; + + case ScalarType.BYTES: + return "z.instanceof(Uint8Array)"; + + default: + return "z.unknown()"; + } +} + +/** + * Maps an enum to Zod z.enum() + */ +function mapEnumToZod( + enumDesc: DescEnum, + context: TypeMapperContext +): ZodTypeInfo { + const enumName = enumDesc.name; + const enumProtoPath = enumDesc.file.name; + + // Import path to the ts-proto generated file (uses .js suffix for ES modules) + const importPath = getRelativeImportPath( + context.currentProtoPath, + enumProtoPath, + ".js" + ); + + return { + zodType: `z.enum(${enumName})`, + needsImport: { + name: enumName, + from: importPath, + }, + }; +} + +/** + * Maps a message to Zod schema reference + */ +function mapMessageToZod( + msgDesc: DescMessage, + context: TypeMapperContext +): ZodTypeInfo { + const typeName = msgDesc.typeName; + + // Handle well-known types + if (typeName === "google.protobuf.Timestamp") { + // Convert to Date object - ts-proto with useDate=true converts Timestamps to Date + return { zodType: "z.coerce.date()" }; + } + + if (typeName === "google.protobuf.Duration") { + return { zodType: "z.string()" }; + } + + if (typeName === "google.protobuf.Any") { + return { zodType: "z.unknown()" }; + } + + // Struct is a flexible JSON-like object + if (typeName === "google.protobuf.Struct") { + return { zodType: "z.record(z.string(), z.any())" }; + } + + // Value is a flexible JSON value + if (typeName === "google.protobuf.Value") { + return { zodType: "z.any()" }; + } + + // ListValue is an array of values + if (typeName === "google.protobuf.ListValue") { + return { zodType: "z.array(z.any())" }; + } + + // Empty message + if (typeName === "google.protobuf.Empty") { + return { zodType: "z.object({})" }; + } + + // For wrapper types + if (typeName === "google.protobuf.StringValue") { + return { zodType: "z.string()" }; + } + if (typeName === "google.protobuf.Int32Value" || typeName === "google.protobuf.Int64Value") { + return { zodType: "z.number().int()" }; + } + if (typeName === "google.protobuf.UInt32Value" || typeName === "google.protobuf.UInt64Value") { + return { zodType: "z.number().int().nonnegative()" }; + } + if (typeName === "google.protobuf.FloatValue" || typeName === "google.protobuf.DoubleValue") { + return { zodType: "z.number()" }; + } + if (typeName === "google.protobuf.BoolValue") { + return { zodType: "z.boolean()" }; + } + if (typeName === "google.protobuf.BytesValue") { + return { zodType: "z.instanceof(Uint8Array)" }; + } + + // For regular messages, reference the schema by name + const schemaName = toSchemaName(msgDesc.name); + const msgProtoPath = msgDesc.file.name; + + // Check if it's in the same file + if (msgProtoPath === context.currentProtoPath) { + return { + zodType: schemaName, + isNestedMessage: true, + }; + } + + // Different file - need to import (uses .js suffix for ES modules) + const importPath = getRelativeImportPath( + context.currentProtoPath, + msgProtoPath, + "_zod.js" + ); + + return { + zodType: schemaName, + isNestedMessage: true, + needsImport: { + name: schemaName, + from: importPath, + }, + }; +} + +/** + * Checks if a field should be marked as optional in Zod + * + * In Proto3, all fields are implicitly optional with default values: + * - Scalars default to zero value ("", 0, false) + * - Messages default to null/undefined + * - Only fields with buf.validate.required = true should be required in Zod + */ +export function isFieldOptional(field: DescField): boolean { + // In Proto3, all scalar and enum fields are optional (have default values) + if (field.fieldKind === "scalar" || field.fieldKind === "enum") { + return true; + } + + // Proto3 explicit optional keyword + if (field.proto.proto3Optional) { + return true; + } + + // Message fields are implicitly optional in proto3 + if (field.fieldKind === "message") { + return true; + } + + // List and map fields are also optional (default to empty) + if (field.fieldKind === "list" || field.fieldKind === "map") { + return true; + } + + return false; +} diff --git a/tools/zod/src/utils.ts b/tools/zod/src/utils.ts new file mode 100644 index 0000000..6068457 --- /dev/null +++ b/tools/zod/src/utils.ts @@ -0,0 +1,128 @@ +/** + * Utility functions for protoc-gen-zod + */ + +/** + * Converts snake_case to camelCase (matching protobuf-es generated field names) + */ +export function toCamelCase(name: string): string { + return name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +/** + * Generates Zod schema export name from message name + * e.g., "RegisterRequest" -> "RegisterRequestSchema" + */ +export function toSchemaName(messageName: string): string { + return `${messageName}Schema`; +} + +/** + * Escapes a string for use in generated code + */ +export function escapeString(str: string): string { + return str + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); +} + +/** + * Generates the import path for a sibling generated file + * e.g., for enum imports from *_pb.ts files + * + * Since we generate files with full paths (mirai/v1/auth_zod.ts), + * imports should be relative within the same directory. + * + * Note: file.name from protobuf-es excludes .proto suffix + */ +export function getRelativeImportPath( + fromProtoPath: string, + toProtoPath: string, + suffix: string +): string { + // Both paths are like "mirai/v1/auth" (without .proto suffix) + // Output files will be "mirai/v1/auth_zod.ts" and "mirai/v1/common_pb.ts" + // We want relative imports like "./common_pb" + const fromDir = fromProtoPath.split("/").slice(0, -1).join("/"); + const toDir = toProtoPath.split("/").slice(0, -1).join("/"); + const toBaseName = toProtoPath.split("/").pop() ?? ""; + + if (fromDir === toDir) { + // Same directory, use ./ + return `./${toBaseName}${suffix}`; + } + + // Different directories - compute relative path + const fromParts = fromDir.split("/").filter(Boolean); + const toParts = toDir.split("/").filter(Boolean); + + // Find common prefix + let commonLength = 0; + for (let i = 0; i < Math.min(fromParts.length, toParts.length); i++) { + if (fromParts[i] === toParts[i]) { + commonLength++; + } else { + break; + } + } + + // Build relative path + const upCount = fromParts.length - commonLength; + const downPath = toParts.slice(commonLength); + const relativeParts = [ + ...Array(upCount).fill(".."), + ...downPath, + `${toBaseName}${suffix}`, + ]; + + return relativeParts.join("/") || `./${toBaseName}${suffix}`; +} + +/** + * Gets the base name of a proto file (last path component) + * Note: file.name from protobuf-es already excludes .proto suffix + * e.g., "mirai/v1/auth" -> "auth" + */ +export function getProtoBaseName(protoPath: string): string { + return protoPath.split("/").pop() ?? ""; +} + +/** + * Checks if a message name ends with "Response" (typically not validated) + */ +export function isResponseMessage(messageName: string): boolean { + return messageName.endsWith("Response"); +} + +/** + * Checks if a message name ends with "Request" (typically needs validation) + */ +export function isRequestMessage(messageName: string): boolean { + return messageName.endsWith("Request"); +} + +/** + * Converts PascalCase or camelCase to SCREAMING_SNAKE_CASE + * e.g., "CostType" -> "COST_TYPE" + */ +export function toScreamingSnakeCase(name: string): string { + return name + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2") + .toUpperCase(); +} + +/** + * Strips the common prefix from an enum value name + * e.g., "COST_TYPE_TOKEN_INPUT" with prefix "COST_TYPE" -> "TOKEN_INPUT" + */ +export function stripEnumPrefix(valueName: string, enumName: string): string { + const prefix = toScreamingSnakeCase(enumName) + "_"; + if (valueName.startsWith(prefix)) { + return valueName.slice(prefix.length); + } + return valueName; +} diff --git a/tools/zod/src/validation-mapper.ts b/tools/zod/src/validation-mapper.ts new file mode 100644 index 0000000..710c935 --- /dev/null +++ b/tools/zod/src/validation-mapper.ts @@ -0,0 +1,543 @@ +/** + * Maps buf.validate annotations to Zod validation chains + * + * This module reads buf.validate field constraints and converts them + * to equivalent Zod validation methods. + */ + +import type { DescField, DescEnum } from "@bufbuild/protobuf"; +import { getExtension, hasExtension } from "@bufbuild/protobuf"; +import { field as fieldExtension } from "@buf/bufbuild_protovalidate.bufbuild_es/buf/validate/validate_pb.js"; + +export interface ValidationChain { + /** Zod methods to chain, e.g., [".min(1)", ".max(100)", ".email()"] */ + methods: string[]; + /** Whether the field is required (not optional) */ + required: boolean; + /** Whether enum should filter out UNSPECIFIED (value 0) */ + enumDefinedOnly: boolean; + /** Zod methods to apply to array items (for repeated fields with items constraints) */ + itemMethods: string[]; + /** Whether enum items should filter out UNSPECIFIED (value 0) */ + itemEnumNotIn: number[]; + /** String pattern constraint (stored separately to handle optional fields) */ + stringPattern?: string; + /** String pattern for array items */ + itemStringPattern?: string; +} + +/** + * Extracts buf.validate constraints from a field and returns Zod validation chain + */ +export function getValidationChain(field: DescField): ValidationChain { + const chain: ValidationChain = { + methods: [], + required: false, + enumDefinedOnly: false, + itemMethods: [], + itemEnumNotIn: [], + }; + + try { + // Get field options - this is where extensions are stored + const options = field.proto.options; + if (!options) { + return chain; + } + + // Check if field has buf.validate.field extension + if (!hasExtension(options, fieldExtension)) { + return chain; + } + + const constraints = getExtension(options, fieldExtension) as { + required?: boolean; + type?: { case: string; value: unknown }; + }; + if (!constraints) { + return chain; + } + + // Check required constraint + if (constraints.required) { + chain.required = true; + } + + // Process type-specific constraints + const type = constraints.type; + if (type) { + switch (type.case) { + case "string": + processStringConstraints(type.value, chain); + break; + case "bytes": + processBytesConstraints(type.value, chain); + break; + case "int32": + case "uint32": + case "sint32": + case "fixed32": + case "sfixed32": + processNumericConstraints(type.value, chain); + break; + case "int64": + case "uint64": + case "sint64": + case "fixed64": + case "sfixed64": + // int64/uint64 are strings in TypeScript (forceLong=string) + processInt64Constraints(type.value, chain); + break; + case "float": + case "double": + processFloatConstraints(type.value, chain); + break; + case "bool": + processBoolConstraints(type.value, chain); + break; + case "enum": + processEnumConstraints(type.value, chain); + break; + case "repeated": + processRepeatedConstraints(type.value, chain); + break; + case "map": + processMapConstraints(type.value, chain); + break; + } + } + } catch (error) { + // If we can't read the extension, return empty chain + // This can happen if protovalidate types aren't fully loaded + console.error(`Warning: Could not read validation constraints for field ${field.name}:`, error); + } + + return chain; +} + +/** + * Process string-specific constraints + */ +function processStringConstraints(constraints: any, chain: ValidationChain): void { + // Length constraints - handle BigInt, skip default values (0) + if (constraints.minLen !== undefined && constraints.minLen > 0n) { + chain.methods.push(`.min(${Number(constraints.minLen)})`); + } + if (constraints.maxLen !== undefined && constraints.maxLen > 0n) { + chain.methods.push(`.max(${Number(constraints.maxLen)})`); + } + if (constraints.len !== undefined && constraints.len > 0n) { + chain.methods.push(`.length(${Number(constraints.len)})`); + } + + // Pattern/regex constraint - stored separately to handle optional fields + // The generator will decide whether to use .regex() or .refine() based on required + if (constraints.pattern) { + chain.stringPattern = constraints.pattern; + } + + // Prefix/suffix constraints + if (constraints.prefix) { + chain.methods.push(`.startsWith("${constraints.prefix}")`); + } + if (constraints.suffix) { + chain.methods.push(`.endsWith("${constraints.suffix}")`); + } + if (constraints.contains) { + chain.methods.push(`.includes("${constraints.contains}")`); + } + + // Well-known format constraints (check wellKnown oneof) + const wellKnown = constraints.wellKnown; + if (wellKnown) { + switch (wellKnown.case) { + case "email": + if (wellKnown.value) chain.methods.push(".email()"); + break; + case "hostname": + if (wellKnown.value) chain.methods.push('.regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/)'); + break; + case "ip": + if (wellKnown.value) chain.methods.push(".ip()"); + break; + case "ipv4": + if (wellKnown.value) chain.methods.push('.ip({ version: "v4" })'); + break; + case "ipv6": + if (wellKnown.value) chain.methods.push('.ip({ version: "v6" })'); + break; + case "uri": + if (wellKnown.value) chain.methods.push(".url()"); + break; + case "uuid": + if (wellKnown.value) chain.methods.push(".uuid()"); + break; + } + } +} + +/** + * Process bytes-specific constraints + */ +function processBytesConstraints(constraints: any, chain: ValidationChain): void { + if (constraints.minLen !== undefined && constraints.minLen > 0n) { + chain.methods.push(`.refine((b) => b.length >= ${Number(constraints.minLen)}, { message: "Bytes must be at least ${constraints.minLen} bytes" })`); + } + // Only generate maxLen if it's explicitly set (> 0), since 0 is the default value + if (constraints.maxLen !== undefined && constraints.maxLen > 0n) { + chain.methods.push(`.refine((b) => b.length <= ${Number(constraints.maxLen)}, { message: "Bytes must be at most ${constraints.maxLen} bytes" })`); + } +} + +/** + * Process numeric (integer) constraints + */ +function processNumericConstraints(constraints: any, chain: ValidationChain): void { + // Handle greaterThan oneof + const greaterThan = constraints.greaterThan; + if (greaterThan) { + switch (greaterThan.case) { + case "gt": + chain.methods.push(`.gt(${Number(greaterThan.value)})`); + break; + case "gte": + chain.methods.push(`.gte(${Number(greaterThan.value)})`); + break; + } + } + + // Handle lessThan oneof + const lessThan = constraints.lessThan; + if (lessThan) { + switch (lessThan.case) { + case "lt": + chain.methods.push(`.lt(${Number(lessThan.value)})`); + break; + case "lte": + chain.methods.push(`.lte(${Number(lessThan.value)})`); + break; + } + } + + // Only generate const constraint if it's explicitly defined + // Since 0 is the default value for numeric fields in protobuf, we check if const is truthy + // or if it's actually 0n (checking via the presence of other constraints that would indicate intent) + // For simplicity, we only generate const if the value is non-zero, as const=0 is extremely rare + if (constraints.const !== undefined && Number(constraints.const) !== 0) { + chain.methods.push(`.refine((n) => n === ${Number(constraints.const)}, { message: "Must equal ${constraints.const}" })`); + } + if (constraints.in && constraints.in.length > 0) { + const values = constraints.in.map((v: any) => Number(v)).join(", "); + chain.methods.push(`.refine((n) => [${values}].includes(n), { message: "Must be one of: ${values}" })`); + } + if (constraints.notIn && constraints.notIn.length > 0) { + const values = constraints.notIn.map((v: any) => Number(v)).join(", "); + chain.methods.push(`.refine((n) => ![${values}].includes(n), { message: "Must not be one of: ${values}" })`); + } +} + +/** + * Process int64/uint64 constraints (these are strings in TypeScript with forceLong=string) + */ +function processInt64Constraints(constraints: any, chain: ValidationChain): void { + // Handle greaterThan oneof - use refine since value is a string + const greaterThan = constraints.greaterThan; + if (greaterThan) { + switch (greaterThan.case) { + case "gt": + chain.methods.push(`.refine((s) => BigInt(s) > ${greaterThan.value}n, { message: "Must be > ${greaterThan.value}" })`); + break; + case "gte": + chain.methods.push(`.refine((s) => BigInt(s) >= ${greaterThan.value}n, { message: "Must be >= ${greaterThan.value}" })`); + break; + } + } + + // Handle lessThan oneof + const lessThan = constraints.lessThan; + if (lessThan) { + switch (lessThan.case) { + case "lt": + chain.methods.push(`.refine((s) => BigInt(s) < ${lessThan.value}n, { message: "Must be < ${lessThan.value}" })`); + break; + case "lte": + chain.methods.push(`.refine((s) => BigInt(s) <= ${lessThan.value}n, { message: "Must be <= ${lessThan.value}" })`); + break; + } + } + + if (constraints.const !== undefined && Number(constraints.const) !== 0) { + chain.methods.push(`.refine((s) => BigInt(s) === ${constraints.const}n, { message: "Must equal ${constraints.const}" })`); + } + if (constraints.in && constraints.in.length > 0) { + const values = constraints.in.map((v: any) => `${v}n`).join(", "); + chain.methods.push(`.refine((s) => [${values}].includes(BigInt(s)), { message: "Must be one of: ${constraints.in.join(", ")}" })`); + } + if (constraints.notIn && constraints.notIn.length > 0) { + const values = constraints.notIn.map((v: any) => `${v}n`).join(", "); + chain.methods.push(`.refine((s) => ![${values}].includes(BigInt(s)), { message: "Must not be one of: ${constraints.notIn.join(", ")}" })`); + } +} + +/** + * Process float/double constraints + */ +function processFloatConstraints(constraints: any, chain: ValidationChain): void { + // Handle greaterThan oneof + const greaterThan = constraints.greaterThan; + if (greaterThan) { + switch (greaterThan.case) { + case "gt": + chain.methods.push(`.gt(${greaterThan.value})`); + break; + case "gte": + chain.methods.push(`.gte(${greaterThan.value})`); + break; + } + } + + // Handle lessThan oneof + const lessThan = constraints.lessThan; + if (lessThan) { + switch (lessThan.case) { + case "lt": + chain.methods.push(`.lt(${lessThan.value})`); + break; + case "lte": + chain.methods.push(`.lte(${lessThan.value})`); + break; + } + } + + if (constraints.finite) { + chain.methods.push(".finite()"); + } +} + +/** + * Process bool constraints + */ +function processBoolConstraints(constraints: any, chain: ValidationChain): void { + if (constraints.const !== undefined) { + chain.methods.push(`.refine((b) => b === ${constraints.const}, { message: "Must be ${constraints.const}" })`); + } +} + +/** + * Process enum constraints + */ +function processEnumConstraints(constraints: any, chain: ValidationChain): void { + if (constraints.definedOnly) { + chain.enumDefinedOnly = true; + } + if (constraints.in && constraints.in.length > 0) { + const values = constraints.in.join(", "); + chain.methods.push(`.refine((e) => [${values}].includes(e), { message: "Must be one of: ${values}" })`); + } + if (constraints.notIn && constraints.notIn.length > 0) { + const values = constraints.notIn.join(", "); + chain.methods.push(`.refine((e) => ![${values}].includes(e), { message: "Must not be one of: ${values}" })`); + } +} + +/** + * Process repeated (array) constraints + */ +function processRepeatedConstraints(constraints: any, chain: ValidationChain): void { + if (constraints.minItems !== undefined && constraints.minItems > 0n) { + chain.methods.push(`.min(${Number(constraints.minItems)})`); + } + // Only generate maxItems if it's explicitly set (> 0), since 0 is the default value + if (constraints.maxItems !== undefined && constraints.maxItems > 0n) { + chain.methods.push(`.max(${Number(constraints.maxItems)})`); + } + if (constraints.unique) { + chain.methods.push('.refine((arr) => new Set(arr).size === arr.length, { message: "Items must be unique" })'); + } + + // Process item-level constraints (e.g., repeated.items.string.uuid) + const items = constraints.items; + if (items && items.type) { + const itemType = items.type; + switch (itemType.case) { + case "string": + processStringItemConstraints(itemType.value, chain); + break; + case "enum": + processEnumItemConstraints(itemType.value, chain); + break; + case "int32": + case "uint32": + case "sint32": + case "fixed32": + case "sfixed32": + processNumericItemConstraints(itemType.value, chain); + break; + case "int64": + case "uint64": + case "sint64": + case "fixed64": + case "sfixed64": + // int64/uint64 items are strings in TypeScript (forceLong=string) + processInt64ItemConstraints(itemType.value, chain); + break; + } + } +} + +/** + * Process string constraints for array items + */ +function processStringItemConstraints(constraints: any, chain: ValidationChain): void { + // Length constraints + if (constraints.minLen !== undefined && constraints.minLen > 0n) { + chain.itemMethods.push(`.min(${Number(constraints.minLen)})`); + } + if (constraints.maxLen !== undefined && constraints.maxLen > 0n) { + chain.itemMethods.push(`.max(${Number(constraints.maxLen)})`); + } + + // Pattern constraint - stored separately to handle optional items + if (constraints.pattern) { + chain.itemStringPattern = constraints.pattern; + } + + // Prefix/suffix constraints + if (constraints.prefix) { + chain.itemMethods.push(`.startsWith("${constraints.prefix}")`); + } + if (constraints.suffix) { + chain.itemMethods.push(`.endsWith("${constraints.suffix}")`); + } + + // Well-known format constraints + const wellKnown = constraints.wellKnown; + if (wellKnown) { + switch (wellKnown.case) { + case "email": + if (wellKnown.value) chain.itemMethods.push(".email()"); + break; + case "uuid": + if (wellKnown.value) chain.itemMethods.push(".uuid()"); + break; + case "uri": + if (wellKnown.value) chain.itemMethods.push(".url()"); + break; + case "ip": + if (wellKnown.value) chain.itemMethods.push(".ip()"); + break; + case "ipv4": + if (wellKnown.value) chain.itemMethods.push('.ip({ version: "v4" })'); + break; + case "ipv6": + if (wellKnown.value) chain.itemMethods.push('.ip({ version: "v6" })'); + break; + } + } +} + +/** + * Process enum constraints for array items + */ +function processEnumItemConstraints(constraints: any, chain: ValidationChain): void { + if (constraints.notIn && constraints.notIn.length > 0) { + chain.itemEnumNotIn = constraints.notIn.map((v: any) => Number(v)); + } +} + +/** + * Process numeric constraints for array items + */ +function processNumericItemConstraints(constraints: any, chain: ValidationChain): void { + const greaterThan = constraints.greaterThan; + if (greaterThan) { + switch (greaterThan.case) { + case "gt": + chain.itemMethods.push(`.gt(${Number(greaterThan.value)})`); + break; + case "gte": + chain.itemMethods.push(`.gte(${Number(greaterThan.value)})`); + break; + } + } + + const lessThan = constraints.lessThan; + if (lessThan) { + switch (lessThan.case) { + case "lt": + chain.itemMethods.push(`.lt(${Number(lessThan.value)})`); + break; + case "lte": + chain.itemMethods.push(`.lte(${Number(lessThan.value)})`); + break; + } + } +} + +/** + * Process int64/uint64 constraints for array items (strings with forceLong=string) + */ +function processInt64ItemConstraints(constraints: any, chain: ValidationChain): void { + const greaterThan = constraints.greaterThan; + if (greaterThan) { + switch (greaterThan.case) { + case "gt": + chain.itemMethods.push(`.refine((s) => BigInt(s) > ${greaterThan.value}n, { message: "Must be > ${greaterThan.value}" })`); + break; + case "gte": + chain.itemMethods.push(`.refine((s) => BigInt(s) >= ${greaterThan.value}n, { message: "Must be >= ${greaterThan.value}" })`); + break; + } + } + + const lessThan = constraints.lessThan; + if (lessThan) { + switch (lessThan.case) { + case "lt": + chain.itemMethods.push(`.refine((s) => BigInt(s) < ${lessThan.value}n, { message: "Must be < ${lessThan.value}" })`); + break; + case "lte": + chain.itemMethods.push(`.refine((s) => BigInt(s) <= ${lessThan.value}n, { message: "Must be <= ${lessThan.value}" })`); + break; + } + } +} + +/** + * Process map constraints + */ +function processMapConstraints(constraints: any, chain: ValidationChain): void { + if (constraints.minPairs !== undefined && constraints.minPairs > 0n) { + chain.methods.push(`.refine((m) => Object.keys(m).length >= ${Number(constraints.minPairs)}, { message: "Map must have at least ${constraints.minPairs} entries" })`); + } + // Only generate maxPairs if it's explicitly set (> 0), since 0 is the default value + if (constraints.maxPairs !== undefined && constraints.maxPairs > 0n) { + chain.methods.push(`.refine((m) => Object.keys(m).length <= ${Number(constraints.maxPairs)}, { message: "Map must have at most ${constraints.maxPairs} entries" })`); + } +} + +/** + * Check if a field is marked as required via buf.validate + */ +export function isFieldRequired(field: DescField): boolean { + try { + const options = field.proto.options; + if (!options) { + return false; + } + if (!hasExtension(options, fieldExtension)) { + return false; + } + const constraints = getExtension(options, fieldExtension) as { required?: boolean }; + return constraints?.required ?? false; + } catch { + return false; + } +} + +/** + * Get enum values excluding UNSPECIFIED (value 0) for defined_only constraint + */ +export function getDefinedEnumValues(enumType: DescEnum): number[] { + return enumType.values.filter(v => v.number !== 0).map(v => v.number); +} diff --git a/tools/zod/tsconfig.json b/tools/zod/tsconfig.json new file mode 100644 index 0000000..b37b6b9 --- /dev/null +++ b/tools/zod/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.gen.json b/tsconfig.gen.json index ccc14ce..18be631 100644 --- a/tsconfig.gen.json +++ b/tsconfig.gen.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2020", - "module": "ESNext", + "module": "CommonJS", "lib": ["ES2020"], "declaration": true, "outDir": "./gen/typescript",