From 61aa7d532cf7e72c25c10e31ddf81a461daefd27 Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sat, 29 Nov 2025 18:46:06 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 12 + README.md | 3 + plugin.lock.json | 69 +++ .../api-contracts-and-zod-validation/SKILL.md | 124 +++++ .../api_route_schema_template.ts | 245 ++++++++ .../entity_schema_template.ts | 210 +++++++ .../schema_templates/form_schema_template.ts | 81 +++ .../server_action_schema_template.ts | 131 +++++ .../references/zod_patterns.md | 524 ++++++++++++++++++ .../scripts/generate_zod_schema.py | 324 +++++++++++ 10 files changed, 1723 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 plugin.lock.json create mode 100644 skills/api-contracts-and-zod-validation/SKILL.md create mode 100644 skills/api-contracts-and-zod-validation/assets/schema_templates/api_route_schema_template.ts create mode 100644 skills/api-contracts-and-zod-validation/assets/schema_templates/entity_schema_template.ts create mode 100644 skills/api-contracts-and-zod-validation/assets/schema_templates/form_schema_template.ts create mode 100644 skills/api-contracts-and-zod-validation/assets/schema_templates/server_action_schema_template.ts create mode 100644 skills/api-contracts-and-zod-validation/references/zod_patterns.md create mode 100644 skills/api-contracts-and-zod-validation/scripts/generate_zod_schema.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..b432f19 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "api-contracts-and-zod-validation", + "description": "Generate Zod schemas and TypeScript types for forms, API routes, and Server Actions with runtime validation. Use this skill when creating API contracts, validating request/response payloads, generating form schemas, adding input validation to Server Actions or route handlers, or ensuring type safety across client-server boundaries. Trigger terms include zod, schema, validation, API contract, form validation, type inference, runtime validation, parse, safeParse, input validation, request validati", + "version": "1.0.0", + "author": { + "name": "Hope Overture", + "email": "support@worldbuilding-app-skills.dev" + }, + "skills": [ + "./skills" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..173502b --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# api-contracts-and-zod-validation + +Generate Zod schemas and TypeScript types for forms, API routes, and Server Actions with runtime validation. Use this skill when creating API contracts, validating request/response payloads, generating form schemas, adding input validation to Server Actions or route handlers, or ensuring type safety across client-server boundaries. Trigger terms include zod, schema, validation, API contract, form validation, type inference, runtime validation, parse, safeParse, input validation, request validati diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..d2b3233 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,69 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:hopeoverture/worldbuilding-app-skills:plugins/api-contracts-and-zod-validation", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "77a0f2121742d8e4932d0e2aa143a1c97aa17596", + "treeHash": "58555bca51fcdfb92b21fedc000b43fd5d48f772e6caadbbf0ede0932b1725d2", + "generatedAt": "2025-11-28T10:17:31.022390Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "api-contracts-and-zod-validation", + "description": "Generate Zod schemas and TypeScript types for forms, API routes, and Server Actions with runtime validation. Use this skill when creating API contracts, validating request/response payloads, generating form schemas, adding input validation to Server Actions or route handlers, or ensuring type safety across client-server boundaries. Trigger terms include zod, schema, validation, API contract, form validation, type inference, runtime validation, parse, safeParse, input validation, request validati", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "d0a0dc2863c094f3005666a3349526352174ef87dfc5280a9489b5069d395622" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "74acb295a7b756b01a5654aebc4c5d02e9f17da4ea9b589fa72d9b2539b69591" + }, + { + "path": "skills/api-contracts-and-zod-validation/SKILL.md", + "sha256": "e4add77daf6cb22a225c9504431afe0d9c4e57ed12f4d5ccf645f38197cf5cd3" + }, + { + "path": "skills/api-contracts-and-zod-validation/references/zod_patterns.md", + "sha256": "e5a4ad84e445e20b59cf22eb81036f310e68fa8c35c487c0e13cd147f335da32" + }, + { + "path": "skills/api-contracts-and-zod-validation/scripts/generate_zod_schema.py", + "sha256": "247dcc77226a6b9d17e3f80d94de29e93558526a309ccc5f5070b9d9fbc1e7a2" + }, + { + "path": "skills/api-contracts-and-zod-validation/assets/schema_templates/form_schema_template.ts", + "sha256": "f552d06a4b68ddfe50e5a81ccc8d8aff5cf5fe8add8abb0d92abb671a876b163" + }, + { + "path": "skills/api-contracts-and-zod-validation/assets/schema_templates/api_route_schema_template.ts", + "sha256": "1c244e70d23e97de636e90c677175962eae3767d3f80f3900397a4ece4ceeb78" + }, + { + "path": "skills/api-contracts-and-zod-validation/assets/schema_templates/entity_schema_template.ts", + "sha256": "2f18355726177380fa66e4898474e752c39b84deb895120abb9d176393969cba" + }, + { + "path": "skills/api-contracts-and-zod-validation/assets/schema_templates/server_action_schema_template.ts", + "sha256": "4db03c84bd401b95dca83f20cb7a281cdb6325541fa2659d5a2b387199347da0" + } + ], + "dirSha256": "58555bca51fcdfb92b21fedc000b43fd5d48f772e6caadbbf0ede0932b1725d2" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/api-contracts-and-zod-validation/SKILL.md b/skills/api-contracts-and-zod-validation/SKILL.md new file mode 100644 index 0000000..0dc2347 --- /dev/null +++ b/skills/api-contracts-and-zod-validation/SKILL.md @@ -0,0 +1,124 @@ +--- +name: api-contracts-and-zod-validation +description: Generate Zod schemas and TypeScript types for forms, API routes, and Server Actions with runtime validation. Use this skill when creating API contracts, validating request/response payloads, generating form schemas, adding input validation to Server Actions or route handlers, or ensuring type safety across client-server boundaries. Trigger terms include zod, schema, validation, API contract, form validation, type inference, runtime validation, parse, safeParse, input validation, request validation, Server Action validation. +--- + +# API Contracts and Zod Validation + +Generate Zod schemas with TypeScript type inference for forms, API routes, and Server Actions. Validate inputs in Server Actions or route handlers with runtime type checking. + +## Core Capabilities + +### 1. Generate Zod Schemas from TypeScript Types + +When creating API contracts or form validations: + +- Analyze existing TypeScript interfaces or types +- Generate equivalent Zod schemas with proper validation rules +- Ensure bidirectional type compatibility (Zod -> TypeScript) +- Use `z.infer` for automatic type inference + +### 2. Form Validation Schemas + +To create form validation with Zod: + +- Generate schemas for React Hook Form, Formik, or native forms +- Include field-level validation rules (min/max length, regex, custom validators) +- Support nested objects, arrays, and complex data structures +- Provide helpful error messages for validation failures +- Use `references/zod_patterns.md` for common validation patterns + +### 3. API Route and Server Action Validation + +To add validation to API endpoints or Server Actions: + +- Wrap handler logic with schema validation using `.parse()` or `.safeParse()` +- Return typed errors for validation failures +- Generate request/response schemas for API contracts +- Validate query parameters, body payloads, and route parameters +- Ensure type safety between client and server code + +### 4. Schema Generation Script + +Use `scripts/generate_zod_schema.py` to automate schema generation: + +```bash +python scripts/generate_zod_schema.py --input types/entities.ts --output schemas/entities.ts +``` + +The script: +- Parses TypeScript interfaces/types +- Generates Zod schemas with appropriate validators +- Preserves JSDoc comments as descriptions +- Handles common patterns (optional fields, unions, enums) + +## Resource Files + +### scripts/generate_zod_schema.py +Automated TypeScript-to-Zod schema generator. Parses TypeScript AST and generates equivalent Zod schemas. + +### references/zod_patterns.md +Common Zod validation patterns including: +- String validation (email, URL, UUID, custom regex) +- Number constraints (min, max, positive, integer) +- Date validation and transformation +- Array and tuple validation +- Object shape validation +- Union and discriminated unions +- Optional and nullable fields +- Custom refinements and transforms +- Error message customization + +### assets/schema_templates/ +Pre-built Zod schema templates: +- `form_schema_template.ts` - Basic form validation +- `api_route_schema_template.ts` - API route request/response +- `server_action_schema_template.ts` - Server Action input/output +- `entity_schema_template.ts` - Database entity validation + +## Usage Workflow + +### For New API Routes + +1. Define request/response TypeScript types +2. Generate Zod schemas using `scripts/generate_zod_schema.py` or templates +3. Add validation middleware to route handler +4. Return typed validation errors on failure + +### For Server Actions + +1. Define input parameters as TypeScript types +2. Create Zod schema for validation +3. Use `.safeParse()` at the beginning of the action +4. Return validation errors in action result +5. Handle errors on the client side + +### For Forms + +1. Define form fields as TypeScript interface +2. Generate Zod schema with field validators +3. Integrate with form library (React Hook Form recommended) +4. Display validation errors inline + +## Best Practices + +- Always use `.safeParse()` instead of `.parse()` to avoid throwing exceptions +- Provide clear, user-friendly error messages +- Validate early (at API boundary or Server Action entry) +- Keep schemas colocated with routes/actions for maintainability +- Use `z.infer` for automatic TypeScript types +- Document validation rules in schema comments +- Test edge cases (empty values, invalid formats, boundary conditions) + +## Integration with Worldbuilding App + +Common use cases for worldbuilding applications: + +- **Entity creation forms**: Validate character, location, faction data +- **Relationship APIs**: Ensure valid entity IDs and relationship types +- **Timeline events**: Validate dates, ordering, and event data +- **Search/filter endpoints**: Validate query parameters and filters +- **Bulk operations**: Validate arrays of entities or updates +- **Import/export**: Validate file formats and data structure + +Consult `references/zod_patterns.md` for specific validation patterns applicable to worldbuilding entities. diff --git a/skills/api-contracts-and-zod-validation/assets/schema_templates/api_route_schema_template.ts b/skills/api-contracts-and-zod-validation/assets/schema_templates/api_route_schema_template.ts new file mode 100644 index 0000000..a8af10f --- /dev/null +++ b/skills/api-contracts-and-zod-validation/assets/schema_templates/api_route_schema_template.ts @@ -0,0 +1,245 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +/** + * API Route validation schema template + * + * Usage: + * 1. Define request and response schemas + * 2. Validate incoming requests with safeParse + * 3. Return typed errors for invalid requests + */ + +// Request schema +const requestSchema = z.object({ + query: z.string().min(1, 'Query is required'), + filters: z.object({ + type: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + dateRange: z.object({ + start: z.string().datetime().optional(), + end: z.string().datetime().optional(), + }).optional(), + }).optional(), + pagination: z.object({ + page: z.number().int().positive().default(1), + limit: z.number().int().min(1).max(100).default(10), + }).optional(), +}); + +// Response schema +const responseSchema = z.object({ + success: z.boolean(), + data: z.any().optional(), + error: z.string().optional(), + details: z.any().optional(), +}); + +// Types +type RequestBody = z.infer; +type ResponseBody = z.infer; + +/** + * POST endpoint with validation + */ +export async function POST(request: NextRequest): Promise> { + try { + // Parse request body + const body = await request.json(); + + // Validate request + const result = requestSchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { + success: false, + error: 'Invalid request body', + details: result.error.format(), + }, + { status: 400 } + ); + } + + // Type-safe validated data + const { query, filters, pagination } = result.data; + + // Perform business logic + const data = await processRequest(query, filters, pagination); + + return NextResponse.json({ + success: true, + data, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: 'Internal server error', + }, + { status: 500 } + ); + } +} + +async function processRequest( + query: string, + filters?: RequestBody['filters'], + pagination?: RequestBody['pagination'] +) { + // Implement your business logic here + return { results: [], total: 0 }; +} + +/** + * GET endpoint with query parameter validation + */ +const queryParamsSchema = z.object({ + id: z.string().uuid('Invalid ID format'), + includeRelated: z.coerce.boolean().optional(), +}); + +export async function GET(request: NextRequest): Promise> { + // Parse query parameters + const searchParams = request.nextUrl.searchParams; + const queryParams = { + id: searchParams.get('id'), + includeRelated: searchParams.get('includeRelated'), + }; + + // Validate query parameters + const result = queryParamsSchema.safeParse(queryParams); + + if (!result.success) { + return NextResponse.json( + { + success: false, + error: 'Invalid query parameters', + details: result.error.format(), + }, + { status: 400 } + ); + } + + const { id, includeRelated } = result.data; + + // Fetch data + const data = await fetchData(id, includeRelated); + + return NextResponse.json({ + success: true, + data, + }); +} + +async function fetchData(id: string, includeRelated?: boolean) { + // Implement your data fetching logic here + return { id, includeRelated }; +} + +/** + * PATCH endpoint with partial update validation + */ +const updateSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().max(500).optional(), + tags: z.array(z.string()).max(10).optional(), + status: z.enum(['active', 'inactive', 'archived']).optional(), +}).refine( + (data) => Object.keys(data).length > 0, + { message: 'At least one field must be provided for update' } +); + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +): Promise> { + // Validate ID from route params + const idResult = z.string().uuid().safeParse(params.id); + + if (!idResult.success) { + return NextResponse.json( + { + success: false, + error: 'Invalid ID format', + }, + { status: 400 } + ); + } + + // Parse and validate request body + const body = await request.json(); + const result = updateSchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { + success: false, + error: 'Invalid update data', + details: result.error.format(), + }, + { status: 400 } + ); + } + + const id = idResult.data; + const updates = result.data; + + // Perform update + const data = await updateData(id, updates); + + return NextResponse.json({ + success: true, + data, + }); +} + +async function updateData(id: string, updates: z.infer) { + // Implement your update logic here + return { id, ...updates }; +} + +// Example: Middleware with validation +export function validateRequest(schema: z.ZodSchema) { + return async (request: NextRequest): Promise<{ data?: T; error?: ResponseBody }> => { + try { + const body = await request.json(); + const result = schema.safeParse(body); + + if (!result.success) { + return { + error: { + success: false, + error: 'Validation failed', + details: result.error.format(), + }, + }; + } + + return { data: result.data }; + } catch { + return { + error: { + success: false, + error: 'Invalid JSON body', + }, + }; + } + }; +} + +// Usage of middleware: +/* +export async function POST(request: NextRequest) { + const { data, error } = await validateRequest(requestSchema)(request); + + if (error) { + return NextResponse.json(error, { status: 400 }); + } + + // Use validated data + console.log(data); + + return NextResponse.json({ success: true }); +} +*/ diff --git a/skills/api-contracts-and-zod-validation/assets/schema_templates/entity_schema_template.ts b/skills/api-contracts-and-zod-validation/assets/schema_templates/entity_schema_template.ts new file mode 100644 index 0000000..694bccc --- /dev/null +++ b/skills/api-contracts-and-zod-validation/assets/schema_templates/entity_schema_template.ts @@ -0,0 +1,210 @@ +import { z } from 'zod'; + +/** + * Entity validation schema template for worldbuilding applications + * + * Common schemas for worldbuilding entities like characters, locations, items, etc. + */ + +// Base entity schema (shared fields across all entities) +const baseEntitySchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1, 'Name is required').max(200), + description: z.string().max(5000).optional(), + tags: z.array(z.string()).max(20), + metadata: z.object({ + createdAt: z.date(), + updatedAt: z.date(), + createdBy: z.string(), + }), +}); + +// Entity types enum +export const entityTypeSchema = z.enum([ + 'character', + 'location', + 'item', + 'faction', + 'event', + 'concept', +]); + +export type EntityType = z.infer; + +// Character entity schema +export const characterSchema = baseEntitySchema.extend({ + type: z.literal('character'), + attributes: z.object({ + age: z.number().int().nonnegative().optional(), + gender: z.string().optional(), + species: z.string().optional(), + occupation: z.string().optional(), + birthplace: z.string().uuid().optional(), // Reference to location entity + }), + relationships: z.array( + z.object({ + targetId: z.string().uuid(), + type: z.string(), + strength: z.number().min(0).max(1).optional(), + }) + ), +}); + +export type Character = z.infer; + +// Location entity schema +export const locationSchema = baseEntitySchema.extend({ + type: z.literal('location'), + attributes: z.object({ + locationType: z.enum(['city', 'region', 'country', 'landmark', 'building', 'other']), + population: z.number().int().nonnegative().optional(), + parentLocation: z.string().uuid().optional(), // Reference to parent location + coordinates: z.object({ + x: z.number(), + y: z.number(), + }).optional(), + }), +}); + +export type Location = z.infer; + +// Item entity schema +export const itemSchema = baseEntitySchema.extend({ + type: z.literal('item'), + attributes: z.object({ + itemType: z.enum(['weapon', 'armor', 'tool', 'artifact', 'consumable', 'other']), + rarity: z.enum(['common', 'uncommon', 'rare', 'legendary']).optional(), + owner: z.string().uuid().optional(), // Reference to character entity + location: z.string().uuid().optional(), // Reference to location entity + }), +}); + +export type Item = z.infer; + +// Faction/Organization entity schema +export const factionSchema = baseEntitySchema.extend({ + type: z.literal('faction'), + attributes: z.object({ + factionType: z.enum(['guild', 'government', 'religion', 'military', 'criminal', 'other']), + size: z.enum(['small', 'medium', 'large', 'massive']).optional(), + headquarters: z.string().uuid().optional(), // Reference to location entity + leader: z.string().uuid().optional(), // Reference to character entity + founded: z.object({ + year: z.number().int(), + month: z.number().int().min(1).max(12).optional(), + day: z.number().int().min(1).max(31).optional(), + }).optional(), + }), + members: z.array( + z.object({ + characterId: z.string().uuid(), + role: z.string(), + joinDate: z.date().optional(), + }) + ), +}); + +export type Faction = z.infer; + +// Event entity schema +export const eventSchema = baseEntitySchema.extend({ + type: z.literal('event'), + attributes: z.object({ + eventType: z.enum(['battle', 'meeting', 'ceremony', 'discovery', 'disaster', 'other']), + date: z.object({ + year: z.number().int(), + month: z.number().int().min(1).max(12).optional(), + day: z.number().int().min(1).max(31).optional(), + }), + location: z.string().uuid().optional(), // Reference to location entity + participants: z.array(z.string().uuid()), // References to character entities + }), +}).refine( + (data) => !data.attributes.date.day || data.attributes.date.month, + { message: 'Month is required when day is specified', path: ['attributes', 'date', 'month'] } +); + +export type Event = z.infer; + +// Concept entity schema (abstract ideas, magic systems, technologies, etc.) +export const conceptSchema = baseEntitySchema.extend({ + type: z.literal('concept'), + attributes: z.object({ + conceptType: z.enum(['magic', 'technology', 'philosophy', 'language', 'custom']), + relatedEntities: z.array(z.string().uuid()), // References to any entity + }), +}); + +export type Concept = z.infer; + +// Union of all entity types +export const entitySchema = z.discriminatedUnion('type', [ + characterSchema, + locationSchema, + itemSchema, + factionSchema, + eventSchema, + conceptSchema, +]); + +export type Entity = z.infer; + +// Relationship schema +export const relationshipSchema = z.object({ + id: z.string().uuid(), + sourceId: z.string().uuid(), + targetId: z.string().uuid(), + type: z.string().min(1).max(50), + description: z.string().max(500).optional(), + strength: z.number().min(0).max(1).optional(), + metadata: z.record(z.string(), z.any()).optional(), + createdAt: z.date(), + updatedAt: z.date(), +}).refine( + (data) => data.sourceId !== data.targetId, + { message: 'Source and target must be different entities', path: ['targetId'] } +); + +export type Relationship = z.infer; + +// Create entity input schema (without auto-generated fields) +export const createEntityInputSchema = z.object({ + type: entityTypeSchema, + name: z.string().min(1).max(200), + description: z.string().max(5000).optional(), + tags: z.array(z.string()).max(20).default([]), + attributes: z.record(z.string(), z.any()), +}); + +export type CreateEntityInput = z.infer; + +// Update entity input schema (partial) +export const updateEntityInputSchema = z.object({ + name: z.string().min(1).max(200).optional(), + description: z.string().max(5000).optional(), + tags: z.array(z.string()).max(20).optional(), + attributes: z.record(z.string(), z.any()).optional(), +}).refine( + (data) => Object.keys(data).length > 0, + { message: 'At least one field must be provided for update' } +); + +export type UpdateEntityInput = z.infer; + +// Query/filter schema for entity search +export const entityQuerySchema = z.object({ + query: z.string().optional(), + types: z.array(entityTypeSchema).optional(), + tags: z.array(z.string()).optional(), + createdAfter: z.date().optional(), + createdBefore: z.date().optional(), + createdBy: z.string().optional(), + pagination: z.object({ + page: z.number().int().positive().default(1), + limit: z.number().int().min(1).max(100).default(10), + }).optional(), + sortBy: z.enum(['name', 'createdAt', 'updatedAt']).default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), +}); + +export type EntityQuery = z.infer; diff --git a/skills/api-contracts-and-zod-validation/assets/schema_templates/form_schema_template.ts b/skills/api-contracts-and-zod-validation/assets/schema_templates/form_schema_template.ts new file mode 100644 index 0000000..f96d195 --- /dev/null +++ b/skills/api-contracts-and-zod-validation/assets/schema_templates/form_schema_template.ts @@ -0,0 +1,81 @@ +import { z } from 'zod'; + +/** + * Form validation schema template + * + * Usage: + * 1. Customize the schema fields for your form + * 2. Use with React Hook Form or other form libraries + * 3. Extract TypeScript type with z.infer + */ + +export const formSchema = z.object({ + // Text input + name: z.string() + .min(1, 'Name is required') + .max(100, 'Name must be 100 characters or less'), + + // Email input + email: z.string() + .email('Invalid email address'), + + // Optional text field + description: z.string() + .max(500, 'Description must be 500 characters or less') + .optional(), + + // Number input + age: z.coerce.number() + .int('Age must be a whole number') + .positive('Age must be positive') + .optional(), + + // Select/dropdown + category: z.enum(['option1', 'option2', 'option3'], { + required_error: 'Please select a category', + }), + + // Checkbox + agreedToTerms: z.boolean() + .refine((val) => val === true, { + message: 'You must agree to the terms', + }), + + // Multi-select + tags: z.array(z.string()) + .min(1, 'At least one tag is required') + .max(10, 'Maximum 10 tags allowed'), + + // Date input + birthDate: z.coerce.date() + .max(new Date(), 'Birth date cannot be in the future') + .optional(), +}); + +// Infer TypeScript type from schema +export type FormData = z.infer; + +// Example: React Hook Form integration +/* +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +function MyForm() { + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(formSchema), + }); + + const onSubmit = (data: FormData) => { + console.log('Valid form data:', data); + }; + + return ( +
+ + {errors.name && {errors.name.message}} + + +
+ ); +} +*/ diff --git a/skills/api-contracts-and-zod-validation/assets/schema_templates/server_action_schema_template.ts b/skills/api-contracts-and-zod-validation/assets/schema_templates/server_action_schema_template.ts new file mode 100644 index 0000000..da9977b --- /dev/null +++ b/skills/api-contracts-and-zod-validation/assets/schema_templates/server_action_schema_template.ts @@ -0,0 +1,131 @@ +'use server'; + +import { z } from 'zod'; + +/** + * Server Action validation schema template + * + * Usage: + * 1. Define input schema for Server Action parameters + * 2. Validate with safeParse at the beginning of the action + * 3. Return validation errors to the client + */ + +// Input schema +const actionInputSchema = z.object({ + id: z.string().uuid('Invalid ID format'), + name: z.string().min(1, 'Name is required').max(100), + type: z.enum(['type1', 'type2', 'type3']), + metadata: z.record(z.string(), z.any()).optional(), +}); + +// Output schema (optional, for type safety) +const actionOutputSchema = z.object({ + success: z.boolean(), + data: z.any().optional(), + errors: z.record(z.array(z.string())).optional(), +}); + +// Types +type ActionInput = z.infer; +type ActionOutput = z.infer; + +/** + * Example Server Action with validation + */ +export async function myServerAction(input: unknown): Promise { + // Validate input + const result = actionInputSchema.safeParse(input); + + if (!result.success) { + return { + success: false, + errors: result.error.flatten().fieldErrors, + }; + } + + // Type-safe validated data + const { id, name, type, metadata } = result.data; + + try { + // Perform server-side logic + // Example: database operation, API call, etc. + const data = await performAction(id, name, type, metadata); + + return { + success: true, + data, + }; + } catch (error) { + return { + success: false, + errors: { + _general: ['An unexpected error occurred'], + }, + }; + } +} + +async function performAction( + id: string, + name: string, + type: string, + metadata?: Record +) { + // Implement your server-side logic here + return { id, name, type, metadata }; +} + +// Example: FormData-based Server Action +export async function formDataAction(formData: FormData): Promise { + const rawData = { + id: formData.get('id'), + name: formData.get('name'), + type: formData.get('type'), + }; + + const result = actionInputSchema.safeParse(rawData); + + if (!result.success) { + return { + success: false, + errors: result.error.flatten().fieldErrors, + }; + } + + // Process validated data + const { id, name, type } = result.data; + + // ... rest of implementation + + return { success: true, data: { id, name, type } }; +} + +// Example: Client-side usage +/* +'use client'; + +import { myServerAction } from './actions'; +import { useState } from 'react'; + +function MyComponent() { + const [errors, setErrors] = useState>({}); + + async function handleSubmit(data: any) { + const result = await myServerAction(data); + + if (!result.success) { + setErrors(result.errors || {}); + } else { + console.log('Success:', result.data); + setErrors({}); + } + } + + return ( +
+ {errors.name && {errors.name[0]}} +
+ ); +} +*/ diff --git a/skills/api-contracts-and-zod-validation/references/zod_patterns.md b/skills/api-contracts-and-zod-validation/references/zod_patterns.md new file mode 100644 index 0000000..ff89d61 --- /dev/null +++ b/skills/api-contracts-and-zod-validation/references/zod_patterns.md @@ -0,0 +1,524 @@ +# Zod Validation Patterns + +Common Zod validation patterns for API contracts, forms, and Server Actions. + +## String Validation + +### Basic String Constraints + +```typescript +import { z } from 'zod'; + +// Required string +const requiredString = z.string(); + +// Optional string +const optionalString = z.string().optional(); + +// String with length constraints +const username = z.string().min(3).max(20); + +// Non-empty string +const nonEmpty = z.string().min(1); + +// String with default value +const withDefault = z.string().default('default value'); +``` + +### Common String Formats + +```typescript +// Email validation +const email = z.string().email(); + +// URL validation +const url = z.string().url(); + +// UUID validation +const uuid = z.string().uuid(); + +// Custom regex pattern +const phoneNumber = z.string().regex(/^\+?[1-9]\d{1,14}$/); + +// Alphanumeric only +const alphanumeric = z.string().regex(/^[a-zA-Z0-9]+$/); + +// Trim whitespace +const trimmed = z.string().trim(); + +// Lowercase transformation +const lowercase = z.string().toLowerCase(); +``` + +### Advanced String Validation + +```typescript +// Custom refinement +const strongPassword = z.string() + .min(8) + .refine( + (val) => /[A-Z]/.test(val) && /[a-z]/.test(val) && /[0-9]/.test(val), + { message: 'Password must contain uppercase, lowercase, and number' } + ); + +// Multiple validations with custom messages +const entityName = z.string() + .min(1, 'Name is required') + .max(100, 'Name must be 100 characters or less') + .regex(/^[a-zA-Z0-9\s-]+$/, 'Name can only contain letters, numbers, spaces, and hyphens'); +``` + +## Number Validation + +```typescript +// Basic number +const age = z.number(); + +// Integer only +const count = z.number().int(); + +// Positive number +const positive = z.number().positive(); + +// Non-negative (>= 0) +const nonNegative = z.number().nonnegative(); + +// Min/max constraints +const percentage = z.number().min(0).max(100); + +// Multiple of +const evenNumber = z.number().multipleOf(2); + +// Finite number (not Infinity or NaN) +const finite = z.number().finite(); + +// Convert string to number +const stringToNumber = z.coerce.number(); +``` + +## Boolean Validation + +```typescript +// Basic boolean +const isActive = z.boolean(); + +// Optional boolean with default +const isPublic = z.boolean().default(false); + +// Coerce to boolean (accepts "true", "false", 1, 0) +const coercedBoolean = z.coerce.boolean(); +``` + +## Date Validation + +```typescript +// Basic date +const createdAt = z.date(); + +// Date with min/max constraints +const futureDate = z.date().min(new Date()); +const pastDate = z.date().max(new Date()); + +// Date range +const dateInRange = z.date() + .min(new Date('2020-01-01')) + .max(new Date('2030-12-31')); + +// Convert string to date +const stringToDate = z.coerce.date(); + +// ISO date string +const isoDate = z.string().datetime(); +``` + +## Array Validation + +```typescript +// Array of strings +const tags = z.array(z.string()); + +// Array with length constraints +const limitedTags = z.array(z.string()).min(1).max(10); + +// Non-empty array +const nonEmptyArray = z.array(z.string()).nonempty(); + +// Array with unique items +const uniqueTags = z.array(z.string()) + .refine((items) => new Set(items).size === items.length, { + message: 'Tags must be unique' + }); + +// Tuple (fixed-length array with different types) +const coordinate = z.tuple([z.number(), z.number()]); +``` + +## Object Validation + +```typescript +// Basic object shape +const user = z.object({ + id: z.string().uuid(), + name: z.string(), + email: z.string().email(), + age: z.number().int().positive().optional(), +}); + +// Nested objects +const entity = z.object({ + id: z.string(), + name: z.string(), + metadata: z.object({ + createdBy: z.string(), + tags: z.array(z.string()), + }), +}); + +// Partial object (all fields optional) +const partialUser = user.partial(); + +// Pick specific fields +const userCredentials = user.pick({ email: true, password: true }); + +// Omit specific fields +const publicUser = user.omit({ password: true }); + +// Extend object +const extendedUser = user.extend({ + role: z.string(), +}); + +// Merge objects +const merged = user.merge(z.object({ role: z.string() })); +``` + +## Union and Intersection Types + +```typescript +// Union (one of multiple types) +const stringOrNumber = z.union([z.string(), z.number()]); + +// Discriminated union +const result = z.discriminatedUnion('status', [ + z.object({ status: z.literal('success'), data: z.any() }), + z.object({ status: z.literal('error'), error: z.string() }), +]); + +// Intersection (combines multiple types) +const timestamped = z.object({ + createdAt: z.date(), + updatedAt: z.date(), +}); +const userWithTimestamps = z.intersection(user, timestamped); +``` + +## Enum and Literal Types + +```typescript +// Enum +const roleEnum = z.enum(['admin', 'user', 'guest']); + +// Literal +const statusLiteral = z.literal('active'); + +// Multiple literals (union) +const status = z.union([ + z.literal('active'), + z.literal('inactive'), + z.literal('pending'), +]); + +// Native enum support +enum Role { + Admin = 'admin', + User = 'user', + Guest = 'guest', +} +const nativeEnum = z.nativeEnum(Role); +``` + +## Optional and Nullable + +```typescript +// Optional (can be undefined) +const optional = z.string().optional(); + +// Nullable (can be null) +const nullable = z.string().nullable(); + +// Both optional and nullable +const optionalNullable = z.string().optional().nullable(); + +// Nullish (undefined or null) +const nullish = z.string().nullish(); +``` + +## Custom Error Messages + +```typescript +// Field-level messages +const username = z.string({ + required_error: 'Username is required', + invalid_type_error: 'Username must be a string', +}); + +// Validation-level messages +const password = z.string() + .min(8, { message: 'Password must be at least 8 characters' }) + .max(100, { message: 'Password is too long' }); + +// Custom refinement with message +const ageValidation = z.number() + .refine((val) => val >= 18, { + message: 'Must be 18 or older', + }); +``` + +## Transformations + +```typescript +// Transform to different type +const numberFromString = z.string().transform((val) => parseInt(val, 10)); + +// Preprocessing +const trimmedString = z.preprocess( + (val) => typeof val === 'string' ? val.trim() : val, + z.string() +); + +// Chain transforms +const normalized = z.string() + .trim() + .toLowerCase() + .transform((val) => val.replace(/\s+/g, '-')); +``` + +## Advanced Patterns + +### Conditional Validation + +```typescript +// Dependent fields +const formSchema = z.object({ + hasAddress: z.boolean(), + address: z.string().optional(), +}).refine( + (data) => !data.hasAddress || (data.hasAddress && data.address), + { message: 'Address is required when hasAddress is true', path: ['address'] } +); +``` + +### Record/Map Validation + +```typescript +// Record with string keys +const metadata = z.record(z.string(), z.any()); + +// Record with specific value type +const scores = z.record(z.string(), z.number()); + +// Map +const map = z.map(z.string(), z.number()); +``` + +### Recursive Types + +```typescript +// Self-referencing type (tree structure) +interface Category { + id: string; + name: string; + children?: Category[]; +} + +const categorySchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string(), + name: z.string(), + children: z.array(categorySchema).optional(), + }) +); +``` + +## Form Integration Patterns + +### React Hook Form + +```typescript +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +const formSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Invalid email address'), + age: z.number().int().positive().optional(), +}); + +type FormData = z.infer; + +function MyForm() { + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(formSchema), + }); + + const onSubmit = (data: FormData) => { + console.log(data); + }; + + return ( +
+ {/* Form fields */} +
+ ); +} +``` + +## API Validation Patterns + +### Server Action with Validation + +```typescript +'use server'; + +import { z } from 'zod'; + +const createEntitySchema = z.object({ + name: z.string().min(1).max(100), + type: z.enum(['character', 'location', 'item']), + description: z.string().optional(), +}); + +export async function createEntity(formData: FormData) { + const rawData = { + name: formData.get('name'), + type: formData.get('type'), + description: formData.get('description'), + }; + + // Validate with safeParse + const result = createEntitySchema.safeParse(rawData); + + if (!result.success) { + return { + success: false, + errors: result.error.flatten().fieldErrors, + }; + } + + // Type-safe data + const { name, type, description } = result.data; + + // Process validated data + // ... + + return { success: true, data: { id: '123' } }; +} +``` + +### API Route with Validation + +```typescript +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +const requestSchema = z.object({ + query: z.string(), + filters: z.object({ + type: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + }).optional(), +}); + +export async function POST(request: NextRequest) { + const body = await request.json(); + + const result = requestSchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { error: 'Invalid request', details: result.error.format() }, + { status: 400 } + ); + } + + const { query, filters } = result.data; + + // Process validated data + // ... + + return NextResponse.json({ results: [] }); +} +``` + +## Worldbuilding-Specific Patterns + +### Entity Validation + +```typescript +const entitySchema = z.object({ + id: z.string().uuid(), + type: z.enum(['character', 'location', 'item', 'faction', 'event']), + name: z.string().min(1).max(200), + description: z.string().max(5000).optional(), + tags: z.array(z.string()).max(20), + attributes: z.record(z.string(), z.any()), + metadata: z.object({ + createdAt: z.date(), + updatedAt: z.date(), + createdBy: z.string(), + }), +}); +``` + +### Relationship Validation + +```typescript +const relationshipSchema = z.object({ + id: z.string().uuid(), + sourceId: z.string().uuid(), + targetId: z.string().uuid(), + type: z.string().min(1).max(50), + strength: z.number().min(0).max(1).optional(), + metadata: z.record(z.string(), z.any()).optional(), +}).refine( + (data) => data.sourceId !== data.targetId, + { message: 'Source and target must be different entities', path: ['targetId'] } +); +``` + +### Timeline Event Validation + +```typescript +const timelineEventSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(200), + date: z.object({ + year: z.number().int(), + month: z.number().int().min(1).max(12).optional(), + day: z.number().int().min(1).max(31).optional(), + }), + description: z.string().max(5000).optional(), + entityIds: z.array(z.string().uuid()), +}).refine( + (data) => { + if (data.date.day && !data.date.month) { + return false; + } + return true; + }, + { message: 'Month is required when day is specified', path: ['date', 'month'] } +); +``` + +## Best Practices + +1. **Use `.safeParse()` over `.parse()`**: Prevents throwing exceptions +2. **Provide clear error messages**: Help users understand what went wrong +3. **Validate early**: At API boundaries and form submissions +4. **Use type inference**: `z.infer` for TypeScript types +5. **Keep schemas colocated**: Near the code that uses them +6. **Document complex validations**: Add comments explaining business rules +7. **Test edge cases**: Empty values, boundary conditions, invalid formats +8. **Use refinements for complex logic**: When built-in validators aren't enough +9. **Consider performance**: Complex validations on large datasets may be slow +10. **Version your schemas**: When APIs evolve, maintain backward compatibility diff --git a/skills/api-contracts-and-zod-validation/scripts/generate_zod_schema.py b/skills/api-contracts-and-zod-validation/scripts/generate_zod_schema.py new file mode 100644 index 0000000..08bddac --- /dev/null +++ b/skills/api-contracts-and-zod-validation/scripts/generate_zod_schema.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +Generate Zod schemas from TypeScript interfaces/types. + +Usage: + python generate_zod_schema.py --input types/entities.ts --output schemas/entities.ts + python generate_zod_schema.py --input-string "interface User { name: string; age: number; }" +""" + +import re +import argparse +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +class TypeScriptToZodConverter: + """Convert TypeScript types to Zod schemas.""" + + # Type mappings from TS to Zod + TYPE_MAP = { + 'string': 'z.string()', + 'number': 'z.number()', + 'boolean': 'z.boolean()', + 'Date': 'z.date()', + 'any': 'z.any()', + 'unknown': 'z.unknown()', + 'null': 'z.null()', + 'undefined': 'z.undefined()', + 'void': 'z.void()', + } + + def __init__(self): + self.interfaces: Dict[str, str] = {} + self.types: Dict[str, str] = {} + self.enums: Dict[str, List[str]] = {} + + def convert_type(self, ts_type: str, optional: bool = False) -> str: + """Convert a TypeScript type to Zod schema.""" + ts_type = ts_type.strip() + + # Handle optional (?) + if ts_type.endswith('?'): + ts_type = ts_type[:-1].strip() + optional = True + + # Handle union with undefined/null + if ' | undefined' in ts_type or ' | null' in ts_type: + optional = True + ts_type = re.sub(r'\s*\|\s*(undefined|null)', '', ts_type).strip() + + # Simple types + if ts_type in self.TYPE_MAP: + zod = self.TYPE_MAP[ts_type] + # Array types + elif ts_type.endswith('[]'): + inner_type = ts_type[:-2].strip() + inner_zod = self.convert_type(inner_type) + zod = f'z.array({inner_zod})' + # Generic Array + elif ts_type.startswith('Array<') and ts_type.endswith('>'): + inner_type = ts_type[6:-1].strip() + inner_zod = self.convert_type(inner_type) + zod = f'z.array({inner_zod})' + # Record + elif ts_type.startswith('Record<'): + match = re.match(r'Record<\s*(.+?)\s*,\s*(.+?)\s*>', ts_type) + if match: + value_type = match.group(2) + value_zod = self.convert_type(value_type) + zod = f'z.record({value_zod})' + else: + zod = 'z.record(z.any())' + # Union types + elif ' | ' in ts_type: + union_types = [t.strip() for t in ts_type.split('|')] + union_zods = [self.convert_type(t) for t in union_types] + zod = f'z.union([{", ".join(union_zods)}])' + # Literal types + elif ts_type.startswith("'") or ts_type.startswith('"'): + zod = f'z.literal({ts_type})' + # Reference to another interface/type/enum + elif ts_type in self.interfaces or ts_type in self.types or ts_type in self.enums: + zod = f'{ts_type}Schema' + else: + # Unknown type - use z.any() with comment + zod = f'z.any() /* TODO: define schema for {ts_type} */' + + # Add optional modifier + if optional: + zod = f'{zod}.optional()' + + return zod + + def parse_interface(self, interface_str: str) -> Tuple[str, List[Tuple[str, str, bool, Optional[str]]]]: + """Parse a TypeScript interface and return name and fields.""" + # Extract interface name + name_match = re.search(r'interface\s+(\w+)', interface_str) + if not name_match: + raise ValueError("Could not parse interface name") + + name = name_match.group(1) + + # Extract fields + fields = [] + field_pattern = re.compile( + r'^\s*(?:/\*\*\s*(.+?)\s*\*/\s*)?' # Optional JSDoc comment + r'(\w+)(\??)\s*:\s*(.+?);?\s*$', # fieldName?: type; + re.MULTILINE + ) + + body_match = re.search(r'\{(.+?)\}', interface_str, re.DOTALL) + if body_match: + body = body_match.group(1) + for match in field_pattern.finditer(body): + description = match.group(1) + field_name = match.group(2) + optional = match.group(3) == '?' + field_type = match.group(4).strip() + fields.append((field_name, field_type, optional, description)) + + return name, fields + + def parse_type_alias(self, type_str: str) -> Tuple[str, str]: + """Parse a TypeScript type alias.""" + match = re.search(r'type\s+(\w+)\s*=\s*(.+?);?\s*$', type_str, re.DOTALL) + if not match: + raise ValueError("Could not parse type alias") + + name = match.group(1) + type_def = match.group(2).strip().rstrip(';') + return name, type_def + + def parse_enum(self, enum_str: str) -> Tuple[str, List[str]]: + """Parse a TypeScript enum.""" + name_match = re.search(r'enum\s+(\w+)', enum_str) + if not name_match: + raise ValueError("Could not parse enum name") + + name = name_match.group(1) + + # Extract enum values + values = [] + body_match = re.search(r'\{(.+?)\}', enum_str, re.DOTALL) + if body_match: + body = body_match.group(1) + value_pattern = re.compile(r'(\w+)\s*(?:=\s*["\'](.+?)["\']\s*)?', re.MULTILINE) + for match in value_pattern.finditer(body): + value = match.group(2) if match.group(2) else match.group(1) + values.append(value) + + return name, values + + def interface_to_zod(self, name: str, fields: List[Tuple[str, str, bool, Optional[str]]]) -> str: + """Convert interface fields to Zod schema.""" + schema_lines = [f'export const {name}Schema = z.object({{'] + + for field_name, field_type, optional, description in fields: + zod_type = self.convert_type(field_type, optional) + + # Add description if present + if description: + desc_line = f' {field_name}: {zod_type}.describe("{description}"),' + else: + desc_line = f' {field_name}: {zod_type},' + + schema_lines.append(desc_line) + + schema_lines.append('});') + schema_lines.append('') + schema_lines.append(f'export type {name} = z.infer;') + + return '\n'.join(schema_lines) + + def type_alias_to_zod(self, name: str, type_def: str) -> str: + """Convert type alias to Zod schema.""" + zod_type = self.convert_type(type_def) + + lines = [ + f'export const {name}Schema = {zod_type};', + '', + f'export type {name} = z.infer;' + ] + + return '\n'.join(lines) + + def enum_to_zod(self, name: str, values: List[str]) -> str: + """Convert enum to Zod schema.""" + quoted_values = [f'"{v}"' for v in values] + + if len(values) == 1: + zod_def = f'z.literal({quoted_values[0]})' + else: + zod_def = f'z.enum([{", ".join(quoted_values)}])' + + lines = [ + f'export const {name}Schema = {zod_def};', + '', + f'export type {name} = z.infer;' + ] + + return '\n'.join(lines) + + def convert_file(self, input_content: str) -> str: + """Convert an entire TypeScript file to Zod schemas.""" + output_lines = [ + "import { z } from 'zod';", + '', + '// Auto-generated Zod schemas', + '' + ] + + # Parse all interfaces + interface_pattern = re.compile( + r'(?:export\s+)?interface\s+\w+\s*\{[^}]*\}', + re.DOTALL + ) + for match in interface_pattern.finditer(input_content): + interface_str = match.group(0) + try: + name, fields = self.parse_interface(interface_str) + self.interfaces[name] = interface_str + except ValueError: + pass + + # Parse all type aliases + type_pattern = re.compile( + r'(?:export\s+)?type\s+\w+\s*=\s*[^;]+;?', + re.DOTALL + ) + for match in type_pattern.finditer(input_content): + type_str = match.group(0) + try: + name, type_def = self.parse_type_alias(type_str) + self.types[name] = type_def + except ValueError: + pass + + # Parse all enums + enum_pattern = re.compile( + r'(?:export\s+)?enum\s+\w+\s*\{[^}]*\}', + re.DOTALL + ) + for match in enum_pattern.finditer(input_content): + enum_str = match.group(0) + try: + name, values = self.parse_enum(enum_str) + self.enums[name] = values + except ValueError: + pass + + # Generate Zod schemas + # Enums first (no dependencies) + for name, values in self.enums.items(): + output_lines.append(self.enum_to_zod(name, values)) + output_lines.append('') + + # Then type aliases + for name, type_def in self.types.items(): + output_lines.append(self.type_alias_to_zod(name, type_def)) + output_lines.append('') + + # Finally interfaces + for name, interface_str in self.interfaces.items(): + _, fields = self.parse_interface(interface_str) + output_lines.append(self.interface_to_zod(name, fields)) + output_lines.append('') + + return '\n'.join(output_lines) + + +def main(): + parser = argparse.ArgumentParser( + description='Generate Zod schemas from TypeScript interfaces/types' + ) + parser.add_argument( + '--input', + help='Input TypeScript file path' + ) + parser.add_argument( + '--output', + help='Output file path for Zod schemas' + ) + parser.add_argument( + '--input-string', + help='Input TypeScript code as string' + ) + + args = parser.parse_args() + + # Read input + if args.input: + input_path = Path(args.input) + if not input_path.exists(): + print(f"Error: Input file not found: {args.input}") + return 1 + input_content = input_path.read_text(encoding='utf-8') + elif args.input_string: + input_content = args.input_string + else: + print("Error: Either --input or --input-string must be provided") + return 1 + + # Convert + converter = TypeScriptToZodConverter() + try: + output_content = converter.convert_file(input_content) + except Exception as e: + print(f"Error during conversion: {e}") + return 1 + + # Write output + if args.output: + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(output_content, encoding='utf-8') + print(f"Generated Zod schemas written to: {args.output}") + else: + print(output_content) + + return 0 + + +if __name__ == '__main__': + exit(main())