434 lines
10 KiB
Markdown
434 lines
10 KiB
Markdown
---
|
|
name: validating-query-inputs
|
|
description: Validate all external input with Zod before Prisma operations. Use when accepting user input, API requests, or form data.
|
|
allowed-tools: Read, Write, Edit
|
|
---
|
|
|
|
# Input Validation with Zod and Prisma 6
|
|
|
|
## Overview
|
|
|
|
Always validate external input with Zod before Prisma operations. Never trust user-provided data, API requests, or form submissions. Use type-safe validation pipelines that match Prisma schema types.
|
|
|
|
## Validation Pipeline
|
|
|
|
```
|
|
External Input → Zod Validation → Transform → Prisma Operation
|
|
```
|
|
|
|
### Pattern
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
const prisma = new PrismaClient()
|
|
|
|
const createUserSchema = z.object({
|
|
email: z.string().email(),
|
|
name: z.string().min(1).max(100),
|
|
age: z.number().int().positive().optional()
|
|
})
|
|
|
|
async function createUser(rawInput: unknown) {
|
|
const validatedData = createUserSchema.parse(rawInput)
|
|
|
|
return await prisma.user.create({
|
|
data: validatedData
|
|
})
|
|
}
|
|
```
|
|
|
|
## Zod Schemas for Prisma Models
|
|
|
|
### Matching Prisma Types
|
|
|
|
```prisma
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
name String
|
|
phone String?
|
|
website String?
|
|
age Int?
|
|
createdAt DateTime @default(now())
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
|
|
const phoneRegex = /^\+?[1-9]\d{1,14}$/
|
|
|
|
const userCreateSchema = z.object({
|
|
email: z.string().email().toLowerCase(),
|
|
name: z.string().min(1).max(100).trim(),
|
|
phone: z.string().regex(phoneRegex).optional(),
|
|
website: z.string().url().optional(),
|
|
age: z.number().int().min(0).max(150).optional()
|
|
})
|
|
|
|
const userUpdateSchema = userCreateSchema.partial()
|
|
|
|
type UserCreateInput = z.infer<typeof userCreateSchema>
|
|
type UserUpdateInput = z.infer<typeof userUpdateSchema>
|
|
```
|
|
|
|
### Common Validation Patterns
|
|
|
|
```typescript
|
|
const emailSchema = z.string().email().toLowerCase().trim()
|
|
|
|
const urlSchema = z.string().url().refine(
|
|
(url) => url.startsWith('https://'),
|
|
{ message: 'URL must use HTTPS' }
|
|
)
|
|
|
|
const phoneSchema = z.string().regex(
|
|
/^\+?[1-9]\d{1,14}$/,
|
|
'Invalid phone number format'
|
|
)
|
|
|
|
const slugSchema = z.string()
|
|
.min(1)
|
|
.max(100)
|
|
.regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens')
|
|
|
|
const dateSchema = z.coerce.date().refine(
|
|
(date) => date > new Date(),
|
|
{ message: 'Date must be in the future' }
|
|
)
|
|
```
|
|
|
|
## Complete Validation Examples
|
|
|
|
### API Route with Validation
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
const prisma = new PrismaClient()
|
|
|
|
const createPostSchema = z.object({
|
|
title: z.string().min(1).max(200),
|
|
content: z.string().min(1),
|
|
authorId: z.string().cuid(),
|
|
published: z.boolean().default(false),
|
|
tags: z.array(z.string()).max(10).optional()
|
|
})
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const rawBody = await request.json()
|
|
const validatedData = createPostSchema.parse(rawBody)
|
|
|
|
const post = await prisma.post.create({
|
|
data: validatedData
|
|
})
|
|
|
|
return Response.json(post)
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return Response.json(
|
|
{ errors: error.errors },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
```
|
|
|
|
### Form Data Validation
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
const prisma = new PrismaClient()
|
|
|
|
const profileUpdateSchema = z.object({
|
|
name: z.string().min(1).max(100).trim(),
|
|
bio: z.string().max(500).trim().optional(),
|
|
website: z.string().url().optional().or(z.literal('')),
|
|
location: z.string().max(100).trim().optional(),
|
|
birthDate: z.coerce.date().max(new Date()).optional()
|
|
})
|
|
|
|
async function updateProfile(userId: string, formData: FormData) {
|
|
const rawData = {
|
|
name: formData.get('name'),
|
|
bio: formData.get('bio'),
|
|
website: formData.get('website'),
|
|
location: formData.get('location'),
|
|
birthDate: formData.get('birthDate')
|
|
}
|
|
|
|
const validatedData = profileUpdateSchema.parse(rawData)
|
|
|
|
return await prisma.user.update({
|
|
where: { id: userId },
|
|
data: validatedData
|
|
})
|
|
}
|
|
```
|
|
|
|
### Nested Object Validation
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
const prisma = new PrismaClient()
|
|
|
|
const addressSchema = z.object({
|
|
street: z.string().min(1).max(200),
|
|
city: z.string().min(1).max(100),
|
|
state: z.string().length(2).toUpperCase(),
|
|
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/)
|
|
})
|
|
|
|
const createCompanySchema = z.object({
|
|
name: z.string().min(1).max(200),
|
|
email: z.string().email().toLowerCase(),
|
|
website: z.string().url().optional(),
|
|
address: addressSchema
|
|
})
|
|
|
|
async function createCompany(rawInput: unknown) {
|
|
const validatedData = createCompanySchema.parse(rawInput)
|
|
|
|
return await prisma.company.create({
|
|
data: {
|
|
name: validatedData.name,
|
|
email: validatedData.email,
|
|
website: validatedData.website,
|
|
address: {
|
|
create: validatedData.address
|
|
}
|
|
},
|
|
include: {
|
|
address: true
|
|
}
|
|
})
|
|
}
|
|
```
|
|
|
|
### Bulk Operation Validation
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
const prisma = new PrismaClient()
|
|
|
|
const bulkUserSchema = z.object({
|
|
users: z.array(
|
|
z.object({
|
|
email: z.string().email().toLowerCase(),
|
|
name: z.string().min(1).max(100),
|
|
role: z.enum(['USER', 'ADMIN'])
|
|
})
|
|
).min(1).max(100)
|
|
})
|
|
|
|
async function createBulkUsers(rawInput: unknown) {
|
|
const validatedData = bulkUserSchema.parse(rawInput)
|
|
|
|
const uniqueEmails = new Set(validatedData.users.map(u => u.email))
|
|
if (uniqueEmails.size !== validatedData.users.length) {
|
|
throw new Error('Duplicate emails in bulk operation')
|
|
}
|
|
|
|
return await prisma.$transaction(
|
|
validatedData.users.map(user =>
|
|
prisma.user.create({ data: user })
|
|
)
|
|
)
|
|
}
|
|
```
|
|
|
|
## Advanced Patterns
|
|
|
|
### Custom Refinements
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
const prisma = new PrismaClient()
|
|
|
|
const passwordSchema = z.string()
|
|
.min(8)
|
|
.refine((pwd) => /[A-Z]/.test(pwd), {
|
|
message: 'Password must contain uppercase letter'
|
|
})
|
|
.refine((pwd) => /[a-z]/.test(pwd), {
|
|
message: 'Password must contain lowercase letter'
|
|
})
|
|
.refine((pwd) => /[0-9]/.test(pwd), {
|
|
message: 'Password must contain number'
|
|
})
|
|
|
|
const registerSchema = z.object({
|
|
email: z.string().email().toLowerCase(),
|
|
password: passwordSchema,
|
|
confirmPassword: z.string()
|
|
}).refine((data) => data.password === data.confirmPassword, {
|
|
message: "Passwords don't match",
|
|
path: ['confirmPassword']
|
|
})
|
|
```
|
|
|
|
### Async Validation
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
const prisma = new PrismaClient()
|
|
|
|
const createUserSchema = z.object({
|
|
email: z.string().email().toLowerCase(),
|
|
username: z.string().min(3).max(30).regex(/^[a-z0-9_]+$/)
|
|
})
|
|
|
|
async function createUserWithChecks(rawInput: unknown) {
|
|
const validatedData = createUserSchema.parse(rawInput)
|
|
|
|
const existing = await prisma.user.findFirst({
|
|
where: {
|
|
OR: [
|
|
{ email: validatedData.email },
|
|
{ username: validatedData.username }
|
|
]
|
|
}
|
|
})
|
|
|
|
if (existing) {
|
|
if (existing.email === validatedData.email) {
|
|
throw new Error('Email already exists')
|
|
}
|
|
if (existing.username === validatedData.username) {
|
|
throw new Error('Username already taken')
|
|
}
|
|
}
|
|
|
|
return await prisma.user.create({
|
|
data: validatedData
|
|
})
|
|
}
|
|
```
|
|
|
|
### Safe Parsing
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
const prisma = new PrismaClient()
|
|
|
|
const updateSettingsSchema = z.object({
|
|
theme: z.enum(['light', 'dark']).default('light'),
|
|
notifications: z.boolean().default(true),
|
|
language: z.string().length(2).default('en')
|
|
})
|
|
|
|
async function updateSettings(userId: string, rawInput: unknown) {
|
|
const result = updateSettingsSchema.safeParse(rawInput)
|
|
|
|
if (!result.success) {
|
|
return {
|
|
success: false,
|
|
errors: result.error.errors
|
|
}
|
|
}
|
|
|
|
const settings = await prisma.userSettings.upsert({
|
|
where: { userId },
|
|
update: result.data,
|
|
create: {
|
|
userId,
|
|
...result.data
|
|
}
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
data: settings
|
|
}
|
|
}
|
|
```
|
|
|
|
## Security Checklist
|
|
|
|
- [ ] All external input validated before Prisma operations
|
|
- [ ] Zod schemas match Prisma model types
|
|
- [ ] Email addresses normalized (toLowerCase)
|
|
- [ ] String inputs trimmed where appropriate
|
|
- [ ] URLs validated and HTTPS enforced
|
|
- [ ] Phone numbers validated with regex
|
|
- [ ] Numeric ranges validated (min/max)
|
|
- [ ] Array lengths limited (prevent DoS)
|
|
- [ ] Unique constraints validated before bulk operations
|
|
- [ ] Async existence checks for unique fields
|
|
- [ ] Error messages don't leak sensitive data
|
|
- [ ] File uploads validated (type, size, content)
|
|
|
|
## Anti-Patterns
|
|
|
|
### Never Trust Input
|
|
|
|
```typescript
|
|
async function createUser(data: any) {
|
|
return await prisma.user.create({ data })
|
|
}
|
|
```
|
|
|
|
### Never Skip Validation for "Internal" Data
|
|
|
|
```typescript
|
|
async function createUserFromAdmin(data: unknown) {
|
|
return await prisma.user.create({ data })
|
|
}
|
|
```
|
|
|
|
### Never Validate After Database Operation
|
|
|
|
```typescript
|
|
async function createUser(data: unknown) {
|
|
const user = await prisma.user.create({ data })
|
|
const validated = schema.parse(user)
|
|
return validated
|
|
}
|
|
```
|
|
|
|
## Type Safety Integration
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
import { Prisma } from '@prisma/client'
|
|
|
|
const userCreateSchema = z.object({
|
|
email: z.string().email(),
|
|
name: z.string()
|
|
}) satisfies z.Schema<Prisma.UserCreateInput>
|
|
|
|
type ValidatedUserInput = z.infer<typeof userCreateSchema>
|
|
```
|
|
|
|
## Related Skills
|
|
|
|
**Zod v4 Validation:**
|
|
|
|
- If normalizing string inputs (trim, toLowerCase), use the transforming-string-methods skill for Zod v4 built-in string transformations
|
|
- If using Zod for schema construction, use the validating-schema-basics skill from zod-4 for core validation patterns
|
|
- If customizing validation error messages, use the customizing-errors skill from zod-4 for error formatting strategies
|
|
- If validating string formats (email, UUID, URL), use the validating-string-formats skill from zod-4 for built-in validators
|
|
|
|
**TypeScript Validation:**
|
|
|
|
- If performing runtime type checking beyond Zod, use the using-runtime-checks skill from typescript for assertion patterns
|
|
- If validating external data sources, use the validating-external-data skill from typescript for comprehensive validation strategies
|