Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -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
|
||||
69
plugin.lock.json
Normal file
69
plugin.lock.json
Normal file
@@ -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": []
|
||||
}
|
||||
}
|
||||
124
skills/api-contracts-and-zod-validation/SKILL.md
Normal file
124
skills/api-contracts-and-zod-validation/SKILL.md
Normal file
@@ -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<typeof schema>` 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<typeof schema>` 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.
|
||||
@@ -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<typeof requestSchema>;
|
||||
type ResponseBody = z.infer<typeof responseSchema>;
|
||||
|
||||
/**
|
||||
* POST endpoint with validation
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<NextResponse<ResponseBody>> {
|
||||
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<NextResponse<ResponseBody>> {
|
||||
// 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<NextResponse<ResponseBody>> {
|
||||
// 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<typeof updateSchema>) {
|
||||
// Implement your update logic here
|
||||
return { id, ...updates };
|
||||
}
|
||||
|
||||
// Example: Middleware with validation
|
||||
export function validateRequest<T>(schema: z.ZodSchema<T>) {
|
||||
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 });
|
||||
}
|
||||
*/
|
||||
@@ -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<typeof entityTypeSchema>;
|
||||
|
||||
// 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<typeof characterSchema>;
|
||||
|
||||
// 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<typeof locationSchema>;
|
||||
|
||||
// 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<typeof itemSchema>;
|
||||
|
||||
// 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<typeof factionSchema>;
|
||||
|
||||
// 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<typeof eventSchema>;
|
||||
|
||||
// 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<typeof conceptSchema>;
|
||||
|
||||
// Union of all entity types
|
||||
export const entitySchema = z.discriminatedUnion('type', [
|
||||
characterSchema,
|
||||
locationSchema,
|
||||
itemSchema,
|
||||
factionSchema,
|
||||
eventSchema,
|
||||
conceptSchema,
|
||||
]);
|
||||
|
||||
export type Entity = z.infer<typeof entitySchema>;
|
||||
|
||||
// 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<typeof relationshipSchema>;
|
||||
|
||||
// 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<typeof createEntityInputSchema>;
|
||||
|
||||
// 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<typeof updateEntityInputSchema>;
|
||||
|
||||
// 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<typeof entityQuerySchema>;
|
||||
@@ -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<typeof formSchema>
|
||||
*/
|
||||
|
||||
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<typeof formSchema>;
|
||||
|
||||
// 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<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
console.log('Valid form data:', data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register('name')} />
|
||||
{errors.name && <span>{errors.name.message}</span>}
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
*/
|
||||
@@ -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<typeof actionInputSchema>;
|
||||
type ActionOutput = z.infer<typeof actionOutputSchema>;
|
||||
|
||||
/**
|
||||
* Example Server Action with validation
|
||||
*/
|
||||
export async function myServerAction(input: unknown): Promise<ActionOutput> {
|
||||
// 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<string, any>
|
||||
) {
|
||||
// Implement your server-side logic here
|
||||
return { id, name, type, metadata };
|
||||
}
|
||||
|
||||
// Example: FormData-based Server Action
|
||||
export async function formDataAction(formData: FormData): Promise<ActionOutput> {
|
||||
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<Record<string, string[]>>({});
|
||||
|
||||
async function handleSubmit(data: any) {
|
||||
const result = await myServerAction(data);
|
||||
|
||||
if (!result.success) {
|
||||
setErrors(result.errors || {});
|
||||
} else {
|
||||
console.log('Success:', result.data);
|
||||
setErrors({});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{errors.name && <span>{errors.name[0]}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
*/
|
||||
@@ -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<Category> = 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<typeof formSchema>;
|
||||
|
||||
function MyForm() {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
console.log(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Form fields */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 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<typeof schema>` 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
|
||||
@@ -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<T>
|
||||
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<K, V>
|
||||
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<typeof {name}Schema>;')
|
||||
|
||||
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<typeof {name}Schema>;'
|
||||
]
|
||||
|
||||
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<typeof {name}Schema>;'
|
||||
]
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user