diff --git a/package.json b/package.json index fa4e9d9..b1eb55a 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "homepage": "https://github.com/DigitalKin-ai/agentic-mesh-protocol#readme", "dependencies": { "@grpc/grpc-js": "^1.14.3", - "google-protobuf": "^4.0.1", + "google-protobuf": "^4.0.2", "zod": "^4.3.6" }, "peerDependencies": { @@ -54,7 +54,7 @@ }, "devDependencies": { "@buf/bufbuild_protovalidate.bufbuild_es": "^2.11.0-20251209175733-2a1774d88802.1", - "@bufbuild/buf": "1.65.0", + "@bufbuild/buf": "1.66.0", "@bufbuild/protobuf": "^2.11.0", "@bufbuild/protoplugin": "^2.11.0", "@types/google-protobuf": "^3.15.12", diff --git a/proto/agentic_mesh_protocol/task_manager/v1/task_manager_dto.proto b/proto/agentic_mesh_protocol/task_manager/v1/task_manager_dto.proto new file mode 100644 index 0000000..d62cd08 --- /dev/null +++ b/proto/agentic_mesh_protocol/task_manager/v1/task_manager_dto.proto @@ -0,0 +1,105 @@ +// Copyright 2025 DigitalKin Inc. +// +// Licensed under the GNU General Public License, Version 3.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package agentic_mesh_protocol.task_manager.v1; + +import "agentic_mesh_protocol/task_manager/v1/task_manager_message.proto"; +import "buf/validate/validate.proto"; + +// SendSignalsRequest +// Request to send signals to tasks (create, stop, etc.). +// The action field on each task determines the signal type. +// +// Fields: +// - tasks: List of tasks with their signal action. +message SendSignalsRequest { + // tasks is the list of tasks with their signal action. + repeated Task tasks = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).repeated.min_items = 1 + ]; +} + +// SendSignalsResponse +// Response after sending signals to tasks. +// +// Fields: +// - success: Whether the operation was successful. +// - tasks: Tasks with updated state after signal processing. +message SendSignalsResponse { + // success indicates whether the operation was successful. + bool success = 1 [(buf.validate.field).required = true]; + // tasks is the list of tasks with updated state. + repeated Task tasks = 2 [(buf.validate.field).required = true]; +} + +// ListHeartbeatsRequest +// Request to list heartbeats for one or more tasks. +// +// Fields: +// - task_ids: List of task identifiers to get heartbeats for. +message ListHeartbeatsRequest { + // task_ids is the list of task identifiers to get heartbeats for. + repeated string task_ids = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).repeated.min_items = 1, + (buf.validate.field).repeated.items.cel = { + id: "task_id_length" + expression: "this.size() >= 1" + message: "task_id must be at least 1 characters long" + } + ]; +} + +// ListHeartbeatsResponse +// Response containing tasks with their current state. +// +// Fields: +// - tasks: Tasks with their current status. +// - total_count: Total number of tasks available. +message ListHeartbeatsResponse { + // total_count is the total number of tasks available. + int32 total_count = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).int32.gte = 0 + ]; + // tasks is the list of tasks with their current status. + repeated Task tasks = 2 [(buf.validate.field).required = true]; +} + +// GetSignalsRequest +// Request to retrieve pending signals for a task. +// Used by task executors to poll for signals. +// +// Fields: +// - task_id: Task to check for signals. +message GetSignalsRequest { + // bulk query for task_ids' signals. + repeated string task_ids = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).repeated.min_items = 1 + ]; +} + +// GetSignalsResponse +// Response containing tasks with pending signals. +// +// Fields: +// - tasks: Tasks with their pending signal state. +message GetSignalsResponse { + // tasks is the list of tasks with pending signal state. + repeated Task tasks = 1 [(buf.validate.field).required = true]; +} diff --git a/proto/agentic_mesh_protocol/task_manager/v1/task_manager_message.proto b/proto/agentic_mesh_protocol/task_manager/v1/task_manager_message.proto new file mode 100644 index 0000000..2b3250d --- /dev/null +++ b/proto/agentic_mesh_protocol/task_manager/v1/task_manager_message.proto @@ -0,0 +1,115 @@ +// Copyright 2025 DigitalKin Inc. +// +// Licensed under the GNU General Public License, Version 3.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package agentic_mesh_protocol.task_manager.v1; + +import "buf/validate/validate.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +// Task +// Represents a unit of work dispatched to a module. +// Includes full task state, signaling, and error info. +// +// Fields: +// - task_id: Unique identifier of the task. +// - mission_id: Mission this task belongs to. +// - setup_id: Setup configuration to use. +// - setup_version_id: Setup version to use. +// - status: Current lifecycle status. +// - action: Latest signal action type. +// - created_at: When the task was created. +// - heartbeat_at: When the task sent its last heartbeat. +// - payload: Optional structured payload. +message Task { + // task_id is the unique identifier of the task. + string task_id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).cel = { + id: "task_id_length" + expression: "this.size() >= 1" + message: "task_id must be at least 1 characters long" + } + ]; + // mission_id is the mission this task belongs to. + string mission_id = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).cel = { + id: "mission_id_prefix" + expression: "this.startsWith('missions:')" + message: "mission_id must start with 'missions:'" + }, + (buf.validate.field).cel = { + id: "mission_id_length" + expression: "this.size() >= 10" + message: "mission_id must be at least 1 characters long" + } + ]; + // setup_id is the setup configuration to use. + string setup_id = 3 [ + (buf.validate.field).required = true, + (buf.validate.field).cel = { + id: "setup_id_prefix" + expression: "this.startsWith('setups:')" + message: "setup_id must start with 'setups:'" + }, + (buf.validate.field).cel = { + id: "setup_id_length" + expression: "this.size() >= 8" + message: "setup_id must be at least 1 characters long" + } + ]; + // setup_version_id is the setup version to use. + string setup_version_id = 4 [ + (buf.validate.field).required = true, + (buf.validate.field).cel = { + id: "setup_version_id_prefix" + expression: "this.startsWith('setup_versions:')" + message: "setup_version_id must start with 'setup_versions:'" + }, + (buf.validate.field).cel = { + id: "setup_version_id_length" + expression: "this.size() >= 16" + message: "setup_version_id must be at least 1 characters long" + } + ]; + // action is the latest signal action type. + string action = 5 [ + (buf.validate.field).required = true, + (buf.validate.field).cel = { + id: "action_length" + expression: "this.size() >= 1" + message: "action must be at least 1 characters long" + } + ]; + // cancellation_reason is the reason for cancellation. + string cancellation_reason = 6 [ + (buf.validate.field).required = true, + (buf.validate.field).cel = { + id: "cancellation_reason_length" + expression: "this.size() >= 1" + message: "action must be at least 1 characters long" + } + ]; + // created_at is when the task was created. + google.protobuf.Timestamp created_at = 7 [ + (buf.validate.field).required = true + ]; + // heartbeat_at is when the task last sent a heartbeat. + optional google.protobuf.Timestamp heartbeat_at = 8; + // payload is an optional structured payload. + optional google.protobuf.Struct payload = 9; +} diff --git a/proto/agentic_mesh_protocol/task_manager/v1/task_manager_service.proto b/proto/agentic_mesh_protocol/task_manager/v1/task_manager_service.proto new file mode 100644 index 0000000..486fdc3 --- /dev/null +++ b/proto/agentic_mesh_protocol/task_manager/v1/task_manager_service.proto @@ -0,0 +1,35 @@ +// Copyright 2025 DigitalKin Inc. +// +// Licensed under the GNU General Public License, Version 3.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package agentic_mesh_protocol.task_manager.v1; + +import "agentic_mesh_protocol/task_manager/v1/task_manager_dto.proto"; + +// TaskManagerService provides task orchestration +// and monitoring for the agentic mesh. +service TaskManagerService { + // SendSignals sends signals to tasks (create, stop, etc.). + rpc SendSignals(SendSignalsRequest) + returns (SendSignalsResponse); + + // ListHeartbeats retrieves heartbeats for a task. + rpc ListHeartbeats(ListHeartbeatsRequest) + returns (ListHeartbeatsResponse); + + // GetSignals retrieves pending signals for a task. + rpc GetSignals(GetSignalsRequest) + returns (GetSignalsResponse); +} diff --git a/proto/buf.lock b/proto/buf.lock index 069cd88..d15a117 100644 --- a/proto/buf.lock +++ b/proto/buf.lock @@ -2,5 +2,5 @@ version: v2 deps: - name: buf.build/bufbuild/protovalidate - commit: 2a1774d888024a9b93ce7eb4b59f6a83 - digest: b5:6b7f9bc919b65e5b79d7b726ffc03d6f815a412d6b792970fa6f065cae162107bd0a9d47272c8ab1a2c9514e87b13d3fbf71df614374d62d2183afb64be2d30a + commit: 80ab13bee0bf4272b6161a72bf7034e0 + digest: b5:1aa6a965be5d02d64e1d81954fa2e78ef9d1e33a0c30f92bc2626039006a94deb3a5b05f14ed8893f5c3ffce444ac008f7e968188ad225c4c29c813aa5f2daa1 diff --git a/tools/zod/src/generator.ts b/tools/zod/src/generator.ts index 160c91e..fcc2ff3 100644 --- a/tools/zod/src/generator.ts +++ b/tools/zod/src/generator.ts @@ -9,7 +9,7 @@ import { isFieldOptional, type TypeMapperContext, } from "./type-mapper.js"; -import { getValidationChain, isFieldRequired } from "./validation-mapper.js"; +import { getValidationChain, isFieldRequired } from "./validation/index.js"; import { toCamelCase, toSchemaName, getRelativeImportPath, toScreamingSnakeCase, stripEnumPrefix } from "./utils.js"; export interface PluginOptions { diff --git a/tools/zod/src/validation-mapper.ts b/tools/zod/src/validation-mapper.ts deleted file mode 100644 index 710c935..0000000 --- a/tools/zod/src/validation-mapper.ts +++ /dev/null @@ -1,543 +0,0 @@ -/** - * 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/src/validation/cel-parser.ts b/tools/zod/src/validation/cel-parser.ts new file mode 100644 index 0000000..5c143b2 --- /dev/null +++ b/tools/zod/src/validation/cel-parser.ts @@ -0,0 +1,241 @@ +/** + * CEL expression → Zod translation + * + * Covers standard CEL string, numeric, size, and logical patterns as + * documented at https://celbyexample.com. Compound expressions joined + * with && are split and each part is translated independently. Anything + * that cannot be mapped to a native Zod method is emitted as a .refine() + * with the original CEL embedded in the error message. + */ + +import type { CelRule } from "./types.js"; + +/** Escape a string for safe embedding inside a JS double-quoted string literal */ +function escapeString(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** Generate a fallback .refine() that always passes but carries the CEL as documentation */ +function celToRefine(expression: string, message: string): string { + const m = escapeString(message || "Validation failed"); + const e = escapeString(expression); + return `.refine((_v) => true, { message: "${m} (CEL: ${e})" })`; +} + +/** + * Split an expression on a top-level logical operator (&& or ||) + * without breaking inside parentheses, brackets, or string literals. + */ +function splitTopLevel(expr: string, operator: string): string[] { + const parts: string[] = []; + let depth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + let current = ""; + + for (let i = 0; i < expr.length; i++) { + const ch = expr[i]; + + if (ch === "'" && !inDoubleQuote && expr[i - 1] !== "\\") { + inSingleQuote = !inSingleQuote; + } else if (ch === '"' && !inSingleQuote && expr[i - 1] !== "\\") { + inDoubleQuote = !inDoubleQuote; + } else if (!inSingleQuote && !inDoubleQuote) { + if (ch === "(" || ch === "[") depth++; + else if (ch === ")" || ch === "]") depth--; + } + + if ( + depth === 0 && + !inSingleQuote && + !inDoubleQuote && + expr.substring(i, i + operator.length) === operator + ) { + parts.push(current); + current = ""; + i += operator.length - 1; // skip rest of operator + } else { + current += ch; + } + } + parts.push(current); + return parts; +} + +/** + * Top-level CEL → Zod translator. + * + * Returns an **array** of Zod method strings because a single CEL rule + * joined with && can map to multiple independent Zod chain methods. + */ +function parseCelExpression(expression: string, message: string): string[] { + const trimmed = expression.trim(); + + // Compound && → split and translate each part independently + const andParts = splitTopLevel(trimmed, "&&"); + if (andParts.length > 1) { + const results: string[] = []; + for (const part of andParts) { + results.push(...parseSingleCelExpression(part.trim(), message)); + } + return results; + } + + // Compound || → single refine (cannot be decomposed) + const orParts = splitTopLevel(trimmed, "||"); + if (orParts.length > 1) { + return [celToRefine(trimmed, message)]; + } + + return parseSingleCelExpression(trimmed, message); +} + +/** + * Translates a single (non-compound) CEL expression to Zod method(s). + */ +function parseSingleCelExpression(expr: string, message: string): string[] { + // ─── String methods on this ──────────────────────────────────── + + // this.startsWith('…') / this.startsWith("…") + const startsWithMatch = expr.match(/^this\.startsWith\(\s*['"](.+?)['"]\s*\)$/); + if (startsWithMatch) return [`.startsWith("${startsWithMatch[1]}")`]; + + // this.endsWith('…') + const endsWithMatch = expr.match(/^this\.endsWith\(\s*['"](.+?)['"]\s*\)$/); + if (endsWithMatch) return [`.endsWith("${endsWithMatch[1]}")`]; + + // this.contains('…') + const containsMatch = expr.match(/^this\.contains\(\s*['"](.+?)['"]\s*\)$/); + if (containsMatch) return [`.includes("${containsMatch[1]}")`]; + + // this.matches('…') (RE2 regex) + const matchesMatch = expr.match(/^this\.matches\(\s*['"](.+?)['"]\s*\)$/); + if (matchesMatch) { + const p = escapeString(matchesMatch[1]); + return [`.regex(new RegExp("${p}"))`]; + } + + // ─── String transformation equality ──────────────────────────── + // this == this.lowerAscii() or this.lowerAscii() == this + if (/^this\s*==\s*this\.lowerAscii\(\)$/.test(expr) || /^this\.lowerAscii\(\)\s*==\s*this$/.test(expr)) { + return [`.refine((v) => v === v.toLowerCase(), { message: "${escapeString(message || "Must be lowercase")}" })`]; + } + // this == this.upperAscii() or this.upperAscii() == this + if (/^this\s*==\s*this\.upperAscii\(\)$/.test(expr) || /^this\.upperAscii\(\)\s*==\s*this$/.test(expr)) { + return [`.refine((v) => v === v.toUpperCase(), { message: "${escapeString(message || "Must be uppercase")}" })`]; + } + // this == this.trim() or this.trim() == this (no leading/trailing whitespace) + if (/^this\s*==\s*this\.trim\(\)$/.test(expr) || /^this\.trim\(\)\s*==\s*this$/.test(expr)) { + return [`.refine((v) => v === v.trim(), { message: "${escapeString(message || "Must not have leading/trailing whitespace")}" })`]; + } + + // ─── size() comparisons (this.size() or size(this)) ────────── + const SIZE = String.raw`(?:this\.size\(\)|size\(this\))`; + + const sizeGte = expr.match(new RegExp(`^${SIZE}\\s*>=\\s*(\\d+)$`)); + if (sizeGte) return [`.min(${sizeGte[1]})`]; + + const sizeGt = expr.match(new RegExp(`^${SIZE}\\s*>\\s*(\\d+)$`)); + if (sizeGt) return [`.min(${Number(sizeGt[1]) + 1})`]; + + const sizeLte = expr.match(new RegExp(`^${SIZE}\\s*<=\\s*(\\d+)$`)); + if (sizeLte) return [`.max(${sizeLte[1]})`]; + + const sizeLt = expr.match(new RegExp(`^${SIZE}\\s*<\\s*(\\d+)$`)); + if (sizeLt) return [`.max(${Number(sizeLt[1]) - 1})`]; + + const sizeEq = expr.match(new RegExp(`^${SIZE}\\s*==\\s*(\\d+)$`)); + if (sizeEq) return [`.length(${sizeEq[1]})`]; + + const sizeNeq = expr.match(new RegExp(`^${SIZE}\\s*!=\\s*(\\d+)$`)); + if (sizeNeq) { + const n = sizeNeq[1]; + return [`.refine((v) => v.length !== ${n}, { message: "${escapeString(message || `Length must not be ${n}`)}" })`]; + } + + // ─── Direct numeric comparisons on this ──────────────────────── + const NUM = String.raw`(-?\d+(?:\.\d+)?)`; + + const numGte = expr.match(new RegExp(`^this\\s*>=\\s*${NUM}$`)); + if (numGte) return [`.gte(${numGte[1]})`]; + + const numGt = expr.match(new RegExp(`^this\\s*>\\s*${NUM}$`)); + if (numGt) return [`.gt(${numGt[1]})`]; + + const numLte = expr.match(new RegExp(`^this\\s*<=\\s*${NUM}$`)); + if (numLte) return [`.lte(${numLte[1]})`]; + + const numLt = expr.match(new RegExp(`^this\\s*<\\s*${NUM}$`)); + if (numLt) return [`.lt(${numLt[1]})`]; + + // ─── Equality with literal ───────────────────────────────────── + // this == 'value' / this == "value" + const strEq = expr.match(/^this\s*==\s*['"](.+?)['"]$/); + if (strEq) { + return [`.refine((v) => v === "${escapeString(strEq[1])}", { message: "${escapeString(message || `Must equal '${strEq[1]}'`)}" })`]; + } + // this != 'value' + const strNeq = expr.match(/^this\s*!=\s*['"](.+?)['"]$/); + if (strNeq) { + return [`.refine((v) => v !== "${escapeString(strNeq[1])}", { message: "${escapeString(message || `Must not equal '${strNeq[1]}'`)}" })`]; + } + + // this == N (numeric) + const numEq = expr.match(new RegExp(`^this\\s*==\\s*${NUM}$`)); + if (numEq) { + return [`.refine((v) => v === ${numEq[1]}, { message: "${escapeString(message || `Must equal ${numEq[1]}`)}" })`]; + } + // this != N + const numNeq = expr.match(new RegExp(`^this\\s*!=\\s*${NUM}$`)); + if (numNeq) { + return [`.refine((v) => v !== ${numNeq[1]}, { message: "${escapeString(message || `Must not equal ${numNeq[1]}`)}" })`]; + } + + // ─── in operator ─────────────────────────────────────────────── + // this in ['a', 'b'] or this in [1, 2] + const inMatch = expr.match(/^this\s+in\s+\[(.+)]$/); + if (inMatch) { + // Convert CEL single-quoted strings to JS double-quoted + const jsElements = inMatch[1].replace(/'/g, '"'); + return [`.refine((v) => [${jsElements}].includes(v), { message: "${escapeString(message || "Must be one of the allowed values")}" })`]; + } + + // ─── Negated expressions ─────────────────────────────────────── + // !this.contains('…') + const negContains = expr.match(/^!\s*this\.contains\(\s*['"](.+?)['"]\s*\)$/); + if (negContains) { + return [`.refine((v) => !v.includes("${negContains[1]}"), { message: "${escapeString(message || `Must not contain '${negContains[1]}'`)}" })`]; + } + // !this.startsWith('…') + const negStartsWith = expr.match(/^!\s*this\.startsWith\(\s*['"](.+?)['"]\s*\)$/); + if (negStartsWith) { + return [`.refine((v) => !v.startsWith("${negStartsWith[1]}"), { message: "${escapeString(message || `Must not start with '${negStartsWith[1]}'`)}" })`]; + } + // !this.endsWith('…') + const negEndsWith = expr.match(/^!\s*this\.endsWith\(\s*['"](.+?)['"]\s*\)$/); + if (negEndsWith) { + return [`.refine((v) => !v.endsWith("${negEndsWith[1]}"), { message: "${escapeString(message || `Must not end with '${negEndsWith[1]}'`)}" })`]; + } + // !this.matches('…') + const negMatches = expr.match(/^!\s*this\.matches\(\s*['"](.+?)['"]\s*\)$/); + if (negMatches) { + const p = escapeString(negMatches[1]); + return [`.refine((v) => !new RegExp("${p}").test(v), { message: "${escapeString(message || "Must not match pattern")}" })`]; + } + + // ─── Fallback: unrecognized CEL → no-op refine with message ─── + return [celToRefine(expr, message)]; +} + +/** + * Process CEL constraints and push results to the given methods array + */ +export function processCelRules(celRules: CelRule[], target: string[]): void { + for (const rule of celRules) { + if (!rule.expression) continue; + const methods = parseCelExpression(rule.expression, rule.message || rule.id || ""); + for (const method of methods) { + target.push(method); + } + } +} diff --git a/tools/zod/src/validation/chain.ts b/tools/zod/src/validation/chain.ts new file mode 100644 index 0000000..5cb69f6 --- /dev/null +++ b/tools/zod/src/validation/chain.ts @@ -0,0 +1,100 @@ +/** + * Extracts buf.validate constraints from protobuf fields and builds ValidationChains + */ + +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"; +import type { ValidationChain, CelRule } from "./types.js"; +import { fieldTarget } from "./types.js"; +import { processTypeConstraints, processRepeatedConstraints, processMapConstraints } from "./constraint-processors.js"; +import { processCelRules } from "./cel-parser.js"; + +/** + * 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 { + const options = field.proto.options; + if (!options) { + return chain; + } + + if (!hasExtension(options, fieldExtension)) { + return chain; + } + + const constraints = getExtension(options, fieldExtension) as { + required?: boolean; + type?: { case: string; value: unknown }; + cel?: CelRule[]; + }; + 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 "repeated": + processRepeatedConstraints(type.value, chain); + break; + case "map": + processMapConstraints(type.value, chain); + break; + default: + processTypeConstraints(type, fieldTarget(chain)); + break; + } + } + + // Process CEL constraints + if (constraints.cel && constraints.cel.length > 0) { + processCelRules(constraints.cel, chain.methods); + } + } catch (error) { + console.error(`Warning: Could not read validation constraints for field ${field.name}:`, error); + } + + return chain; +} + +/** + * 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/src/validation/constraint-processors.ts b/tools/zod/src/validation/constraint-processors.ts new file mode 100644 index 0000000..4b63b23 --- /dev/null +++ b/tools/zod/src/validation/constraint-processors.ts @@ -0,0 +1,287 @@ +/** + * Constraint processors for buf.validate type-specific rules. + * + * Each processor is unified via MethodTarget to handle both field-level + * and item-level constraints without duplication. + */ + +import type { ValidationChain, MethodTarget } from "./types.js"; +import { itemTarget } from "./types.js"; +import { processCelRules } from "./cel-parser.js"; + +/** + * Dispatches to the correct processor based on constraint type case. + * Used by both getValidationChain (field-level) and processRepeatedConstraints (item-level). + */ +export function processTypeConstraints( + type: { case: string; value: unknown }, + target: MethodTarget, +): void { + switch (type.case) { + case "string": + processStringConstraints(type.value, target); + break; + case "bytes": + processBytesConstraints(type.value, target); + break; + case "int32": + case "uint32": + case "sint32": + case "fixed32": + case "sfixed32": + processNumericConstraints(type.value, target); + break; + case "int64": + case "uint64": + case "sint64": + case "fixed64": + case "sfixed64": + // int64/uint64 are strings in TypeScript (forceLong=string) + processInt64Constraints(type.value, target); + break; + case "float": + case "double": + processFloatConstraints(type.value, target); + break; + case "bool": + processBoolConstraints(type.value, target); + break; + case "enum": + processEnumConstraints(type.value, target); + break; + } +} + +/** Process string-specific constraints */ +function processStringConstraints(constraints: any, target: MethodTarget): void { + // Length constraints - handle BigInt, skip default values (0) + if (constraints.minLen !== undefined && constraints.minLen > 0n) { + target.methods.push(`.min(${Number(constraints.minLen)})`); + } + if (constraints.maxLen !== undefined && constraints.maxLen > 0n) { + target.methods.push(`.max(${Number(constraints.maxLen)})`); + } + if (constraints.len !== undefined && constraints.len > 0n) { + target.methods.push(`.length(${Number(constraints.len)})`); + } + + // Pattern/regex constraint - stored separately to handle optional fields + if (constraints.pattern) { + target.setStringPattern(constraints.pattern); + } + + // Prefix/suffix constraints + if (constraints.prefix) { + target.methods.push(`.startsWith("${constraints.prefix}")`); + } + if (constraints.suffix) { + target.methods.push(`.endsWith("${constraints.suffix}")`); + } + if (constraints.contains) { + target.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) target.methods.push(".email()"); + break; + case "hostname": + if (wellKnown.value) target.methods.push('.regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/)'); + break; + case "ip": + if (wellKnown.value) target.methods.push(".ip()"); + break; + case "ipv4": + if (wellKnown.value) target.methods.push('.ip({ version: "v4" })'); + break; + case "ipv6": + if (wellKnown.value) target.methods.push('.ip({ version: "v6" })'); + break; + case "uri": + if (wellKnown.value) target.methods.push(".url()"); + break; + case "uuid": + if (wellKnown.value) target.methods.push(".uuid()"); + break; + } + } +} + +/** Process bytes-specific constraints */ +function processBytesConstraints(constraints: any, target: MethodTarget): void { + if (constraints.minLen !== undefined && constraints.minLen > 0n) { + target.methods.push(`.refine((b) => b.length >= ${Number(constraints.minLen)}, { message: "Bytes must be at least ${constraints.minLen} bytes" })`); + } + if (constraints.maxLen !== undefined && constraints.maxLen > 0n) { + target.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, target: MethodTarget): void { + const greaterThan = constraints.greaterThan; + if (greaterThan) { + switch (greaterThan.case) { + case "gt": + target.methods.push(`.gt(${Number(greaterThan.value)})`); + break; + case "gte": + target.methods.push(`.gte(${Number(greaterThan.value)})`); + break; + } + } + + const lessThan = constraints.lessThan; + if (lessThan) { + switch (lessThan.case) { + case "lt": + target.methods.push(`.lt(${Number(lessThan.value)})`); + break; + case "lte": + target.methods.push(`.lte(${Number(lessThan.value)})`); + break; + } + } + + if (constraints.const !== undefined && Number(constraints.const) !== 0) { + target.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(", "); + target.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(", "); + target.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, target: MethodTarget): void { + const greaterThan = constraints.greaterThan; + if (greaterThan) { + switch (greaterThan.case) { + case "gt": + target.methods.push(`.refine((s) => BigInt(s) > ${greaterThan.value}n, { message: "Must be > ${greaterThan.value}" })`); + break; + case "gte": + target.methods.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": + target.methods.push(`.refine((s) => BigInt(s) < ${lessThan.value}n, { message: "Must be < ${lessThan.value}" })`); + break; + case "lte": + target.methods.push(`.refine((s) => BigInt(s) <= ${lessThan.value}n, { message: "Must be <= ${lessThan.value}" })`); + break; + } + } + + if (constraints.const !== undefined && Number(constraints.const) !== 0) { + target.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(", "); + target.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(", "); + target.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, target: MethodTarget): void { + const greaterThan = constraints.greaterThan; + if (greaterThan) { + switch (greaterThan.case) { + case "gt": + target.methods.push(`.gt(${greaterThan.value})`); + break; + case "gte": + target.methods.push(`.gte(${greaterThan.value})`); + break; + } + } + + const lessThan = constraints.lessThan; + if (lessThan) { + switch (lessThan.case) { + case "lt": + target.methods.push(`.lt(${lessThan.value})`); + break; + case "lte": + target.methods.push(`.lte(${lessThan.value})`); + break; + } + } + + if (constraints.finite) { + target.methods.push(".finite()"); + } +} + +/** Process bool constraints */ +function processBoolConstraints(constraints: any, target: MethodTarget): void { + if (constraints.const !== undefined) { + target.methods.push(`.refine((b) => b === ${constraints.const}, { message: "Must be ${constraints.const}" })`); + } +} + +/** Process enum constraints */ +function processEnumConstraints(constraints: any, target: MethodTarget): void { + if (constraints.definedOnly) { + target.setEnumDefinedOnly(true); + } + if (constraints.in && constraints.in.length > 0) { + const values = constraints.in.join(", "); + target.methods.push(`.refine((e) => [${values}].includes(e), { message: "Must be one of: ${values}" })`); + } + if (constraints.notIn && constraints.notIn.length > 0) { + target.setEnumNotIn(constraints.notIn.map((v: any) => Number(v))); + } +} + +/** Process repeated (array) constraints */ +export function processRepeatedConstraints(constraints: any, chain: ValidationChain): void { + if (constraints.minItems !== undefined && constraints.minItems > 0n) { + chain.methods.push(`.min(${Number(constraints.minItems)})`); + } + 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 via the unified processTypeConstraints + const items = constraints.items; + if (items) { + if (items.type) { + processTypeConstraints(items.type, itemTarget(chain)); + } + + // Process item-level CEL constraints + if (items.cel && items.cel.length > 0) { + processCelRules(items.cel, chain.itemMethods); + } + } +} + +/** Process map constraints */ +export 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" })`); + } + 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" })`); + } +} diff --git a/tools/zod/src/validation/index.ts b/tools/zod/src/validation/index.ts new file mode 100644 index 0000000..08e8f5d --- /dev/null +++ b/tools/zod/src/validation/index.ts @@ -0,0 +1,6 @@ +/** + * Barrel re-export for the validation module + */ + +export type { ValidationChain } from "./types.js"; +export { getValidationChain, isFieldRequired, getDefinedEnumValues } from "./chain.js"; diff --git a/tools/zod/src/validation/types.ts b/tools/zod/src/validation/types.ts new file mode 100644 index 0000000..fc6adf7 --- /dev/null +++ b/tools/zod/src/validation/types.ts @@ -0,0 +1,73 @@ +/** + * Types and abstractions for buf.validate → Zod validation mapping + */ + +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; +} + +/** Represents a single CEL constraint rule from buf.validate */ +export interface CelRule { + id: string; + message: string; + expression: string; +} + +/** + * Abstraction over field-level vs item-level targets. + * Allows constraint processors to be written once and used for both. + */ +export interface MethodTarget { + methods: string[]; + setStringPattern: (pattern: string) => void; + setEnumDefinedOnly: (value: boolean) => void; + setEnumNotIn: (values: number[]) => void; +} + +/** Creates a MethodTarget that writes to field-level properties of a ValidationChain */ +export function fieldTarget(chain: ValidationChain): MethodTarget { + return { + methods: chain.methods, + setStringPattern: (pattern: string) => { + chain.stringPattern = pattern; + }, + setEnumDefinedOnly: (value: boolean) => { + chain.enumDefinedOnly = value; + }, + setEnumNotIn: (values: number[]) => { + const valuesStr = values.join(", "); + chain.methods.push( + `.refine((e) => ![${valuesStr}].includes(e), { message: "Must not be one of: ${valuesStr}" })`, + ); + }, + }; +} + +/** Creates a MethodTarget that writes to item-level properties of a ValidationChain */ +export function itemTarget(chain: ValidationChain): MethodTarget { + return { + methods: chain.itemMethods, + setStringPattern: (pattern: string) => { + chain.itemStringPattern = pattern; + }, + setEnumDefinedOnly: (value: boolean) => { + chain.enumDefinedOnly = value; + }, + setEnumNotIn: (values: number[]) => { + chain.itemEnumNotIn = values; + }, + }; +}