diff --git a/CHANGELOG.md b/CHANGELOG.md index 33673d3..c989eb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.0] - 2024-03-XX +## [0.1.3] - 2024-03-XX + +### Added +- `vertexSchemaToZod` function for converting Vertex AI schemas back to Zod schemas +- Two-way conversion support between Zod and Vertex AI schemas +- Improved documentation with examples for both conversion directions + +## [0.1.2] - 2024-03-13 + +### Fixed +- Fixed imports and package structure + +## [0.1.0] - 2024-03-12 ### Added - Initial release of `zod-to-vertex-schema` diff --git a/README.md b/README.md index dbfccfe..abe92ff 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,16 @@ [![npm version](https://badge.fury.io/js/@techery%2Fzod-to-vertex-schema.svg)](https://www.npmjs.com/package/@techery/zod-to-vertex-schema) ![CI Status](https://github.com/techery/zod-to-vertex-schema/actions/workflows/pr-checks.yml/badge.svg?branch=main) -Convert [Zod](https://github.com/colinhacks/zod) schemas to [Vertex AI/Gemini](https://cloud.google.com/vertex-ai) compatible schemas. This library helps you define your Vertex AI/Gemini function parameters using Zod's powerful schema definition system. +Convert [Zod](https://github.com/colinhacks/zod) schemas to [Vertex AI/Gemini](https://cloud.google.com/vertex-ai) compatible schemas and back. This library helps you define your Vertex AI/Gemini function parameters using Zod's powerful schema definition system. Developed by [Techery](https://techery.io). +## Features +- Two-way conversion between Zod and Vertex AI schemas +- Preserves property ordering and descriptions +- Full TypeScript support +- Zero dependencies (except Zod) + ## Type Compatibility | Zod Type | Vertex AI Schema Type | Notes | @@ -36,6 +42,61 @@ npm install zod-to-vertex-schema ## Usage +### Converting Zod to Vertex AI Schema + +```typescript +import { z } from 'zod'; +import { zodToVertexSchema } from 'zod-to-vertex-schema'; + +// Define your Zod schema +const userSchema = z.object({ + name: z.string(), + age: z.number().int().min(0), + email: z.string().email(), + roles: z.array(z.enum(['admin', 'user'])), +}); + +// Convert to Vertex AI schema +const vertexSchema = zodToVertexSchema(userSchema); +``` + +### Converting Vertex AI Schema to Zod + +```typescript +import { vertexSchemaToZod } from 'zod-to-vertex-schema'; +import { SchemaType } from 'zod-to-vertex-schema'; + +// Your Vertex AI schema +const vertexSchema = { + type: SchemaType.OBJECT, + properties: { + name: { type: SchemaType.STRING }, + age: { type: SchemaType.INTEGER, minimum: 0 }, + email: { type: SchemaType.STRING }, + roles: { + type: SchemaType.ARRAY, + items: { type: SchemaType.STRING, enum: ["admin", "user"] } + } + }, + required: ["name", "age", "email", "roles"], + propertyOrdering: ["name", "age", "email", "roles"] +}; + +// Convert back to Zod schema +const zodSchema = vertexSchemaToZod(vertexSchema); + +// Now you can use it for validation +const validUser = { + name: "John", + age: 30, + email: "john@example.com", + roles: ["admin"] +}; + +const result = zodSchema.safeParse(validUser); +console.log(result.success); // true +``` + ### Basic Example ```typescript @@ -302,6 +363,10 @@ const vertexEventSchema = zodToVertexSchema(eventSchema); Converts any Zod schema into a Vertex AI/Gemini-compatible schema. +### `vertexSchemaToZod(schema: VertexSchema): z.ZodTypeAny` + +Converts a Vertex AI/Gemini schema back into a Zod schema. Supports all features that are available in the Vertex AI schema format. + ### `zodDynamicEnum(values: string[]): z.ZodEnum` Helper function to create an enum schema from a dynamic array of strings. @@ -328,4 +393,4 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## License -MIT +MIT \ No newline at end of file diff --git a/package.json b/package.json index bf6ead8..4978615 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@techery/zod-to-vertex-schema", - "version": "0.1.2", + "version": "0.1.3", "description": "Convert Zod schemas to Vertex AI/Gemini compatible schemas", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/index.ts b/src/index.ts index 4870957..a445eae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export { zodToVertexSchema, zodDynamicEnum } from './zod-to-vertex-schema'; +export { vertexSchemaToZod } from './vertex-schema-to-zod'; diff --git a/src/vertex-schema-to-zod.test.ts b/src/vertex-schema-to-zod.test.ts new file mode 100644 index 0000000..8c6ab7c --- /dev/null +++ b/src/vertex-schema-to-zod.test.ts @@ -0,0 +1,274 @@ +// test/vertexSchemaToZod.test.ts +import { describe, it, expect } from '@jest/globals'; +import { z } from 'zod'; +import { SchemaType, VertexSchema } from '../src/vertex-schema'; +import { zodToVertexSchema } from '../src/zod-to-vertex-schema'; +import { vertexSchemaToZod } from '../src/vertex-schema-to-zod'; + +describe('vertexSchemaToZod', () => { + describe('Basic Types', () => { + it('should convert STRING with format="date-time" to z.string().datetime()', () => { + const input: VertexSchema = { + type: SchemaType.STRING, + format: 'date-time', + description: 'A datetime string', + }; + const zodSchema = vertexSchemaToZod(input); + expect(zodSchema).toBeInstanceOf(z.ZodString); + + // Check metadata + expect(zodSchema._def.description).toBe('A datetime string'); + + // Ensure it does produce a .datetime() check + const result = zodSchema.safeParse(new Date().toISOString()); + expect(result.success).toBe(true); + }); + + it('should convert STRING with no enum/format to z.string()', () => { + const input: VertexSchema = { + type: SchemaType.STRING, + description: 'Just a plain string', + }; + const zodSchema = vertexSchemaToZod(input); + expect(zodSchema).toBeInstanceOf(z.ZodString); + expect(zodSchema._def.description).toBe('Just a plain string'); + const result = zodSchema.safeParse('hello'); + expect(result.success).toBe(true); + }); + + it('should convert BOOLEAN to z.boolean()', () => { + const input: VertexSchema = { + type: SchemaType.BOOLEAN, + description: 'A boolean value', + }; + const zodSchema = vertexSchemaToZod(input); + expect(zodSchema).toBeInstanceOf(z.ZodBoolean); + expect(zodSchema._def.description).toBe('A boolean value'); + }); + + it('should convert NUMBER with minimum & maximum to z.number().min().max()', () => { + const input: VertexSchema = { + type: SchemaType.NUMBER, + description: 'A floating-point number', + minimum: 1.5, + maximum: 3.2, + }; + const zodSchema = vertexSchemaToZod(input); + expect(zodSchema).toBeInstanceOf(z.ZodNumber); + expect(zodSchema._def.description).toBe('A floating-point number'); + + // Check .safeParse + expect(zodSchema.safeParse(1).success).toBe(false); + expect(zodSchema.safeParse(2).success).toBe(true); + expect(zodSchema.safeParse(4).success).toBe(false); + }); + + it('should convert INTEGER with min/max to z.number().int().min().max()', () => { + const input: VertexSchema = { + type: SchemaType.INTEGER, + description: 'An integer value', + minimum: 0, + maximum: 10, + }; + const zodSchema = vertexSchemaToZod(input); + expect(zodSchema).toBeInstanceOf(z.ZodNumber); + expect(zodSchema._def.description).toBe('An integer value'); + + // .int() check + expect(zodSchema.safeParse(5).success).toBe(true); + expect(zodSchema.safeParse(5.1).success).toBe(false); + }); + }); + + describe('Enum & Literal', () => { + it('should convert multiple string enum => z.enum()', () => { + const input: VertexSchema = { + type: SchemaType.STRING, + enum: ['RED', 'GREEN', 'BLUE'], + description: 'Color enum', + }; + const zodSchema = vertexSchemaToZod(input); + expect(zodSchema).toBeInstanceOf(z.ZodEnum); + expect(zodSchema._def.values).toEqual(['RED', 'GREEN', 'BLUE']); + expect(zodSchema._def.description).toBe('Color enum'); + + // Check parse + expect(zodSchema.safeParse('RED').success).toBe(true); + expect(zodSchema.safeParse('PURPLE').success).toBe(false); + }); + + it('should convert single string enum => z.literal()', () => { + const input: VertexSchema = { + type: SchemaType.STRING, + enum: ['ONE_VALUE_ONLY'], + }; + const zodSchema = vertexSchemaToZod(input); + expect(zodSchema).toBeInstanceOf(z.ZodLiteral); + expect(zodSchema._def.value).toBe('ONE_VALUE_ONLY'); + + const valid = zodSchema.safeParse('ONE_VALUE_ONLY'); + const invalid = zodSchema.safeParse('foo'); + expect(valid.success).toBe(true); + expect(invalid.success).toBe(false); + }); + }); + + describe('Objects', () => { + it('should convert OBJECT with properties, required, optional', () => { + const input: VertexSchema = { + type: SchemaType.OBJECT, + properties: { + name: { type: SchemaType.STRING }, + age: { type: SchemaType.INTEGER }, + nick: { type: SchemaType.STRING }, + }, + required: ['name', 'age'], + description: 'Person object', + }; + + const zodSchema = vertexSchemaToZod(input); + // -- changed line: + const shape = (zodSchema as z.ZodObject).shape; + + expect(shape.name).toBeTruthy(); + expect(shape.age).toBeTruthy(); + expect(shape.nick).toBeTruthy(); + + // name is required => no .optional() + expect(shape.name.isOptional()).toBe(false); + // nick is not in required => .optional() + expect(shape.nick.isOptional()).toBe(true); + + // test .safeParse + const valid = zodSchema.safeParse({ name: 'John', age: 25 }); + expect(valid.success).toBe(true); + + const missingName = zodSchema.safeParse({ age: 25 }); + expect(missingName.success).toBe(false); + }); + + it('should handle nested OBJECT definitions', () => { + const input: VertexSchema = { + type: SchemaType.OBJECT, + description: 'Nested example', + properties: { + user: { + type: SchemaType.OBJECT, + properties: { + id: { type: SchemaType.INTEGER }, + username: { type: SchemaType.STRING }, + }, + required: ['id'], + }, + }, + required: ['user'], + }; + + const zodSchema = vertexSchemaToZod(input); + // -- changed line: + const shape = (zodSchema as z.ZodObject).shape; + expect(shape.user).toBeTruthy(); + + const result = zodSchema.safeParse({ + user: { id: 1, username: 'test' }, + }); + expect(result.success).toBe(true); + }); + }); + + describe('Arrays', () => { + it('should convert ARRAY with minItems, maxItems', () => { + const input: VertexSchema = { + type: SchemaType.ARRAY, + description: 'Array of numbers', + items: { + type: SchemaType.NUMBER, + }, + minItems: 1, + maxItems: 3, + }; + + const zodSchema = vertexSchemaToZod(input); + expect(zodSchema).toBeInstanceOf(z.ZodArray); + + // check .safeParse + expect(zodSchema.safeParse([]).success).toBe(false); + expect(zodSchema.safeParse([1]).success).toBe(true); + expect(zodSchema.safeParse([1, 2, 3]).success).toBe(true); + expect(zodSchema.safeParse([1, 2, 3, 4]).success).toBe(false); + }); + + it('should throw if ARRAY with no items', () => { + const input: VertexSchema = { + type: SchemaType.ARRAY, + }; + expect(() => vertexSchemaToZod(input)).toThrowError(/ARRAY type schema must have 'items'/i); + }); + }); + + describe('Union (anyOf)', () => { + it('should convert anyOf => z.union([...])', () => { + const input: VertexSchema = { + anyOf: [{ type: SchemaType.STRING }, { type: SchemaType.NUMBER }], + description: 'Union of string or number', + }; + + const zodSchema = vertexSchemaToZod(input); + expect(zodSchema).toBeInstanceOf(z.ZodUnion); + expect(zodSchema._def.description).toBe('Union of string or number'); + + // safeParse checks + expect(zodSchema.safeParse('hello').success).toBe(true); + expect(zodSchema.safeParse(123).success).toBe(true); + expect(zodSchema.safeParse({}).success).toBe(false); + }); + }); + + describe('Nullable', () => { + it('should attach .nullable() if schema.nullable = true', () => { + const input: VertexSchema = { + type: SchemaType.STRING, + nullable: true, + }; + const zodSchema = vertexSchemaToZod(input); + const resultNull = zodSchema.safeParse(null); + const resultString = zodSchema.safeParse('non-null'); + expect(resultNull.success).toBe(true); + expect(resultString.success).toBe(true); + }); + }); + + describe('Round-Trip Check (zodToVertexSchema -> vertexSchemaToZod)', () => { + /** + * This test demonstrates going from a Zod schema -> VertexSchema -> back to Zod + * and verifying the shapes are “compatible.” Because we are not necessarily doing + * a 1:1 perfect mirror of all Zod details, it won't match 100% in some advanced + * cases, but for basic scenarios, it should align. + */ + it('should handle a simple object round-trip', () => { + // 1) Original Zod schema + const originalZod = z + .object({ + name: z.string().describe('Name field'), + age: z.number().min(10).max(100).describe('Age field'), + }) + .describe('A user object'); + + // 2) Convert to VertexSchema + const vertex = zodToVertexSchema(originalZod); + + // 3) Convert back to Zod + const reconstructedZod = vertexSchemaToZod(vertex); + + // 4) Test shape equivalences + const validData = { name: 'Bob', age: 55 }; + const invalidData = { name: 'Bob', age: 9 }; + + expect(originalZod.safeParse(validData).success).toBe(true); + expect(reconstructedZod.safeParse(validData).success).toBe(true); + + expect(originalZod.safeParse(invalidData).success).toBe(false); + expect(reconstructedZod.safeParse(invalidData).success).toBe(false); + }); + }); +}); diff --git a/src/vertex-schema-to-zod.ts b/src/vertex-schema-to-zod.ts new file mode 100644 index 0000000..91d1cb4 --- /dev/null +++ b/src/vertex-schema-to-zod.ts @@ -0,0 +1,183 @@ +import { z } from 'zod'; +import { SchemaType, VertexSchema } from './vertex-schema'; + +export function vertexSchemaToZod(schema: VertexSchema): z.ZodTypeAny { + // 1) If `anyOf` is present, treat it as a union of possibilities. + if (schema.anyOf && schema.anyOf.length > 0) { + const unionVariants = schema.anyOf.map((sub) => vertexSchemaToZod(sub)); + // Attach description or other metadata on the union if needed + let unionSchema = z.union( + unionVariants as unknown as readonly [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]] + ); + if (schema.description) { + unionSchema = unionSchema.describe(schema.description); + } + return applyNullable(unionSchema, schema); + } + + // 2) Otherwise, dispatch on the schema.type + switch (schema.type) { + case SchemaType.STRING: + return makeStringZod(schema); + + case SchemaType.NUMBER: + case SchemaType.INTEGER: + return makeNumberZod(schema); + + case SchemaType.BOOLEAN: + return makeBooleanZod(schema); + + case SchemaType.ARRAY: + return makeArrayZod(schema); + + case SchemaType.OBJECT: + return makeObjectZod(schema); + + default: + // If no type is given or it's an unknown type, fall back to an "unknown" Zod type. + // Or you could throw an error, depending on your needs. + throw new Error(`Unsupported or missing schema.type: ${schema.type}`); + } +} + +/** + * STRING handling: + * - If `enum` is present with more than one value, use z.enum. + * - If `enum` is present with exactly one value, use z.literal. + * - Otherwise plain z.string(), plus `.datetime()` if format==='date-time', etc. + */ +function makeStringZod(schema: VertexSchema): z.ZodTypeAny { + const { format, description, enum: maybeEnum } = schema; + + if (maybeEnum && maybeEnum.length > 1) { + // multiple enumerated string values => z.enum([...]) + let out = z.enum(maybeEnum as [string, ...string[]]); + if (description) out = out.describe(description); + return applyNullable(out, schema); + } else if (maybeEnum && maybeEnum.length === 1) { + // single enumerated value => z.literal(...) + let out = z.literal(maybeEnum[0]); + if (description) out = out.describe(description); + return applyNullable(out, schema); + } + + // Otherwise, a basic string + let strZod = z.string(); + if (format === 'date-time') { + // Zod v3 has `z.string().datetime()` + strZod = strZod.datetime(); + } + // If you want to handle `date` or other custom formats: + // - you might do a refine, or skip it, depending on your needs + + if (description) { + strZod = strZod.describe(description); + } + + return applyNullable(strZod, schema); +} + +/** + * NUMBER or INTEGER handling: + * - Use z.number(), plus .int() if type=INTEGER + * - Apply .min(schema.minimum), .max(schema.maximum) + */ +function makeNumberZod(schema: VertexSchema): z.ZodTypeAny { + let numZod = z.number(); + if (schema.type === SchemaType.INTEGER) { + numZod = numZod.int(); + } + + if (typeof schema.minimum === 'number') { + numZod = numZod.min(schema.minimum); + } + if (typeof schema.maximum === 'number') { + numZod = numZod.max(schema.maximum); + } + + if (schema.description) { + numZod = numZod.describe(schema.description); + } + + return applyNullable(numZod, schema); +} + +/** + * BOOLEAN handling: + * - Just z.boolean(), with optional `.describe(...)` + */ +function makeBooleanZod(schema: VertexSchema): z.ZodTypeAny { + let boolZod = z.boolean(); + if (schema.description) { + boolZod = boolZod.describe(schema.description); + } + return applyNullable(boolZod, schema); +} + +/** + * ARRAY handling: + * - Must have `items` + * - Use z.array(itemsZod) + * - Apply .min(schema.minItems), .max(schema.maxItems) + */ +function makeArrayZod(schema: VertexSchema): z.ZodTypeAny { + if (!schema.items) { + throw new Error(`ARRAY type schema must have 'items'`); + } + + const elementZod = vertexSchemaToZod(schema.items); + let arrZod = z.array(elementZod); + + if (typeof schema.minItems === 'number') { + arrZod = arrZod.min(schema.minItems); + } + if (typeof schema.maxItems === 'number') { + arrZod = arrZod.max(schema.maxItems); + } + + if (schema.description) { + arrZod = arrZod.describe(schema.description); + } + + return applyNullable(arrZod, schema); +} + +/** + * OBJECT handling: + * - Each key in `properties` => shape entry + * - If key not in `required`, mark as .optional() + */ +function makeObjectZod(schema: VertexSchema): z.ZodTypeAny { + const { properties = {}, required = [], description } = schema; + + const shape: Record = {}; + + for (const key of Object.keys(properties)) { + const propertySchema = properties[key]; + let zodField = vertexSchemaToZod(propertySchema); + + if (!required.includes(key)) { + zodField = zodField.optional(); + } + shape[key] = zodField; + } + + let objectZod = z.object(shape); + if (description) { + objectZod = objectZod.describe(description); + } + + return applyNullable(objectZod, schema); +} + +/** + * If schema.nullable = true, attach .nullable(). + * (This does NOT handle the difference between "nullable" vs. "optional"; + * typically you'll handle "optional" if the property is NOT in `required`.) + */ +function applyNullable(zodSchema: T, schema: VertexSchema): T { + if (schema.nullable) { + return zodSchema.nullable() as unknown as T; + } + return zodSchema; +}