Initial commit
This commit is contained in:
598
commands/validate-schemas.md
Normal file
598
commands/validate-schemas.md
Normal file
@@ -0,0 +1,598 @@
|
||||
---
|
||||
description: Validate API schemas
|
||||
shortcut: schema
|
||||
---
|
||||
|
||||
# Validate API Schemas
|
||||
|
||||
Implement comprehensive schema validation using modern validation libraries like JSON Schema, Joi, Yup, or Zod to ensure type safety, data integrity, and contract compliance across your API.
|
||||
|
||||
## When to Use This Command
|
||||
|
||||
Use `/validate-schemas` when you need to:
|
||||
- Enforce strict data types and formats in API requests and responses
|
||||
- Create reusable validation schemas across multiple endpoints
|
||||
- Generate TypeScript types from validation schemas
|
||||
- Implement complex conditional validation logic
|
||||
- Provide detailed validation error messages to clients
|
||||
- Ensure data consistency before database operations
|
||||
|
||||
DON'T use this when:
|
||||
- Working with unstructured or highly dynamic data (use runtime checks instead)
|
||||
- Building quick prototypes without formal contracts (premature optimization)
|
||||
- Validation logic is trivial (simple type checks may suffice)
|
||||
|
||||
## Design Decisions
|
||||
|
||||
This command implements **Zod** as the primary approach because:
|
||||
- TypeScript-first with automatic type inference
|
||||
- Composable schemas with chaining syntax
|
||||
- Zero runtime dependencies
|
||||
- Excellent error messages out of the box
|
||||
- Schema transformation and parsing capabilities
|
||||
- Works seamlessly with modern frameworks
|
||||
|
||||
**Alternative considered: Joi**
|
||||
- More mature with extensive ecosystem
|
||||
- Better for JavaScript-only projects
|
||||
- More verbose API
|
||||
- Recommended for legacy Node.js applications
|
||||
|
||||
**Alternative considered: JSON Schema**
|
||||
- Language-agnostic standard
|
||||
- Better for cross-platform validation
|
||||
- More complex to write and maintain
|
||||
- Recommended when sharing schemas across different languages
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this command:
|
||||
1. Choose validation library based on your tech stack
|
||||
2. Define validation requirements for each endpoint
|
||||
3. Plan error response format
|
||||
4. Consider performance impact for complex schemas
|
||||
5. Determine validation strategy (fail-fast vs. collect-all-errors)
|
||||
|
||||
## Implementation Process
|
||||
|
||||
### Step 1: Define Base Schemas
|
||||
Create reusable schema primitives for common data types and patterns.
|
||||
|
||||
### Step 2: Compose Endpoint Schemas
|
||||
Build complex schemas by composing base schemas with business rules.
|
||||
|
||||
### Step 3: Integrate with Middleware
|
||||
Set up validation middleware to automatically validate requests and responses.
|
||||
|
||||
### Step 4: Generate Types
|
||||
Generate TypeScript types from schemas for compile-time safety.
|
||||
|
||||
### Step 5: Implement Error Handling
|
||||
Create consistent error formatting and reporting mechanisms.
|
||||
|
||||
## Output Format
|
||||
|
||||
The command generates:
|
||||
- `schemas/` - Schema definitions organized by domain
|
||||
- `validators/` - Compiled validation functions
|
||||
- `types/` - Generated TypeScript types from schemas
|
||||
- `middleware/validation.ts` - Request/response validation middleware
|
||||
- `tests/schema.test.ts` - Schema validation test suites
|
||||
- `docs/validation-rules.md` - Documentation of all validation rules
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Example 1: Zod Schema Validation (TypeScript)
|
||||
|
||||
```typescript
|
||||
// schemas/user.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
// Base schemas for reuse
|
||||
const emailSchema = z.string().email().toLowerCase();
|
||||
const passwordSchema = z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain special character');
|
||||
|
||||
const phoneSchema = z.string().regex(
|
||||
/^\+?[1-9]\d{1,14}$/,
|
||||
'Invalid phone number format (E.164)'
|
||||
);
|
||||
|
||||
const addressSchema = z.object({
|
||||
street: z.string().min(1).max(100),
|
||||
city: z.string().min(1).max(50),
|
||||
state: z.string().length(2).toUpperCase(),
|
||||
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
|
||||
country: z.string().length(2).toUpperCase().default('US')
|
||||
});
|
||||
|
||||
// User schemas
|
||||
export const createUserSchema = z.object({
|
||||
body: z.object({
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
firstName: z.string().min(1).max(50),
|
||||
lastName: z.string().min(1).max(50),
|
||||
dateOfBirth: z.string().datetime().refine(
|
||||
(date) => {
|
||||
const age = new Date().getFullYear() - new Date(date).getFullYear();
|
||||
return age >= 18;
|
||||
},
|
||||
{ message: 'Must be at least 18 years old' }
|
||||
),
|
||||
phone: phoneSchema.optional(),
|
||||
address: addressSchema.optional(),
|
||||
preferences: z.object({
|
||||
newsletter: z.boolean().default(false),
|
||||
notifications: z.enum(['email', 'sms', 'push', 'none']).default('email'),
|
||||
theme: z.enum(['light', 'dark', 'auto']).default('auto')
|
||||
}).default({}),
|
||||
metadata: z.record(z.string(), z.any()).optional()
|
||||
})
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
params: z.object({
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
body: createUserSchema.shape.body.partial().extend({
|
||||
// Can't update email without verification
|
||||
email: z.never().optional()
|
||||
})
|
||||
});
|
||||
|
||||
export const getUserSchema = z.object({
|
||||
params: z.object({
|
||||
id: z.string().uuid()
|
||||
})
|
||||
});
|
||||
|
||||
export const listUsersSchema = z.object({
|
||||
query: z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
sort: z.enum(['createdAt', 'email', 'name']).default('createdAt'),
|
||||
order: z.enum(['asc', 'desc']).default('desc'),
|
||||
filter: z.object({
|
||||
email: z.string().optional(),
|
||||
status: z.enum(['active', 'inactive', 'suspended']).optional(),
|
||||
createdAfter: z.string().datetime().optional(),
|
||||
createdBefore: z.string().datetime().optional()
|
||||
}).optional()
|
||||
})
|
||||
});
|
||||
|
||||
// Type inference
|
||||
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||
export type GetUserInput = z.infer<typeof getUserSchema>;
|
||||
export type ListUsersInput = z.infer<typeof listUsersSchema>;
|
||||
|
||||
// Validation middleware
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function validate(schema: z.ZodSchema) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validated = await schema.parseAsync({
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
params: req.params
|
||||
});
|
||||
|
||||
// Replace request properties with validated/transformed data
|
||||
req.body = validated.body || req.body;
|
||||
req.query = validated.query || req.query;
|
||||
req.params = validated.params || req.params;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: error.errors.map(err => ({
|
||||
path: err.path.join('.'),
|
||||
message: err.message,
|
||||
code: err.code
|
||||
}))
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Usage in routes
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/users',
|
||||
validate(createUserSchema),
|
||||
async (req, res) => {
|
||||
// req.body is now typed and validated
|
||||
const user = await createUser(req.body);
|
||||
res.json(user);
|
||||
}
|
||||
);
|
||||
|
||||
router.put('/users/:id',
|
||||
validate(updateUserSchema),
|
||||
async (req, res) => {
|
||||
const user = await updateUser(req.params.id, req.body);
|
||||
res.json(user);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Example 2: Joi Schema Validation (JavaScript)
|
||||
|
||||
```javascript
|
||||
// schemas/product.schema.js
|
||||
const Joi = require('joi');
|
||||
|
||||
// Custom validators
|
||||
const customValidators = {
|
||||
sku: Joi.string().pattern(/^[A-Z]{3}-\d{6}$/),
|
||||
price: Joi.number().precision(2).positive().max(999999.99),
|
||||
url: Joi.string().uri({ scheme: ['http', 'https'] })
|
||||
};
|
||||
|
||||
// Product schemas
|
||||
const productSchema = {
|
||||
create: Joi.object({
|
||||
sku: customValidators.sku.required(),
|
||||
name: Joi.string().min(3).max(200).required(),
|
||||
description: Joi.string().max(2000).required(),
|
||||
price: customValidators.price.required(),
|
||||
compareAtPrice: customValidators.price
|
||||
.greater(Joi.ref('price'))
|
||||
.optional(),
|
||||
category: Joi.string().valid(
|
||||
'electronics',
|
||||
'clothing',
|
||||
'food',
|
||||
'books',
|
||||
'other'
|
||||
).required(),
|
||||
tags: Joi.array()
|
||||
.items(Joi.string().min(2).max(20))
|
||||
.max(10)
|
||||
.unique(),
|
||||
inventory: Joi.object({
|
||||
quantity: Joi.number().integer().min(0).required(),
|
||||
trackInventory: Joi.boolean().default(true),
|
||||
allowBackorder: Joi.boolean().default(false),
|
||||
lowStockThreshold: Joi.number().integer().min(0).default(10)
|
||||
}).required(),
|
||||
images: Joi.array()
|
||||
.items(Joi.object({
|
||||
url: customValidators.url.required(),
|
||||
alt: Joi.string().max(200),
|
||||
isPrimary: Joi.boolean().default(false)
|
||||
}))
|
||||
.min(1)
|
||||
.max(10)
|
||||
.unique('url')
|
||||
.required(),
|
||||
shipping: Joi.object({
|
||||
weight: Joi.number().positive().required(),
|
||||
dimensions: Joi.object({
|
||||
length: Joi.number().positive().required(),
|
||||
width: Joi.number().positive().required(),
|
||||
height: Joi.number().positive().required()
|
||||
}).required(),
|
||||
requiresShipping: Joi.boolean().default(true),
|
||||
shippingClass: Joi.string().valid('standard', 'fragile', 'oversized')
|
||||
}).when('category', {
|
||||
is: 'electronics',
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional()
|
||||
}),
|
||||
variants: Joi.array()
|
||||
.items(Joi.object({
|
||||
name: Joi.string().required(),
|
||||
options: Joi.array()
|
||||
.items(Joi.string())
|
||||
.min(1)
|
||||
.required()
|
||||
}))
|
||||
.optional(),
|
||||
metadata: Joi.object().pattern(
|
||||
Joi.string(),
|
||||
Joi.alternatives().try(
|
||||
Joi.string(),
|
||||
Joi.number(),
|
||||
Joi.boolean()
|
||||
)
|
||||
).optional()
|
||||
}).custom((value, helpers) => {
|
||||
// Custom validation: ensure at least one primary image
|
||||
const primaryImages = value.images.filter(img => img.isPrimary);
|
||||
if (primaryImages.length !== 1) {
|
||||
return helpers.error('any.invalid', {
|
||||
message: 'Exactly one image must be marked as primary'
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
|
||||
update: Joi.object({
|
||||
name: Joi.string().min(3).max(200),
|
||||
description: Joi.string().max(2000),
|
||||
price: customValidators.price,
|
||||
// ... partial schema
|
||||
}).min(1), // At least one field required
|
||||
|
||||
list: Joi.object({
|
||||
page: Joi.number().integer().positive().default(1),
|
||||
limit: Joi.number().integer().min(1).max(100).default(20),
|
||||
category: Joi.string(),
|
||||
minPrice: Joi.number().positive(),
|
||||
maxPrice: Joi.number().positive().greater(Joi.ref('minPrice')),
|
||||
search: Joi.string().max(100),
|
||||
inStock: Joi.boolean()
|
||||
})
|
||||
};
|
||||
|
||||
// Validation middleware
|
||||
function validateRequest(schemaName) {
|
||||
return async (req, res, next) => {
|
||||
const schema = productSchema[schemaName];
|
||||
if (!schema) {
|
||||
return next(new Error(`Schema ${schemaName} not found`));
|
||||
}
|
||||
|
||||
const dataToValidate = {
|
||||
...req.body,
|
||||
...req.query,
|
||||
...req.params
|
||||
};
|
||||
|
||||
try {
|
||||
const validated = await schema.validateAsync(dataToValidate, {
|
||||
abortEarly: false, // Return all errors
|
||||
stripUnknown: true, // Remove unknown keys
|
||||
convert: true // Type coercion
|
||||
});
|
||||
|
||||
// Merge validated data back
|
||||
Object.keys(validated).forEach(key => {
|
||||
if (req.body.hasOwnProperty(key)) req.body[key] = validated[key];
|
||||
if (req.query.hasOwnProperty(key)) req.query[key] = validated[key];
|
||||
if (req.params.hasOwnProperty(key)) req.params[key] = validated[key];
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.isJoi) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: error.details.map(detail => ({
|
||||
field: detail.path.join('.'),
|
||||
message: detail.message,
|
||||
type: detail.type
|
||||
}))
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
productSchema,
|
||||
validateRequest
|
||||
};
|
||||
```
|
||||
|
||||
### Example 3: Pydantic Schema Validation (Python)
|
||||
|
||||
```python
|
||||
# schemas/order_schema.py
|
||||
from pydantic import BaseModel, Field, validator, root_validator
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
import re
|
||||
|
||||
class OrderStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
SHIPPED = "shipped"
|
||||
DELIVERED = "delivered"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
class PaymentMethod(str, Enum):
|
||||
CREDIT_CARD = "credit_card"
|
||||
DEBIT_CARD = "debit_card"
|
||||
PAYPAL = "paypal"
|
||||
STRIPE = "stripe"
|
||||
BANK_TRANSFER = "bank_transfer"
|
||||
|
||||
class Address(BaseModel):
|
||||
street: str = Field(..., min_length=1, max_length=200)
|
||||
city: str = Field(..., min_length=1, max_length=100)
|
||||
state: str = Field(..., regex="^[A-Z]{2}$")
|
||||
zip_code: str = Field(..., regex=r"^\d{5}(-\d{4})?$")
|
||||
country: str = Field(default="US", regex="^[A-Z]{2}$")
|
||||
|
||||
@validator('state', 'country')
|
||||
def uppercase_codes(cls, v):
|
||||
return v.upper()
|
||||
|
||||
class OrderItem(BaseModel):
|
||||
product_id: str = Field(..., regex="^[A-Z]{3}-\d{6}$")
|
||||
quantity: int = Field(..., gt=0, le=100)
|
||||
unit_price: Decimal = Field(..., decimal_places=2, ge=0)
|
||||
discount: Optional[Decimal] = Field(None, decimal_places=2, ge=0, le=1)
|
||||
|
||||
@validator('discount')
|
||||
def validate_discount(cls, v, values):
|
||||
if v and v >= 1:
|
||||
raise ValueError('Discount must be less than 100%')
|
||||
return v
|
||||
|
||||
@property
|
||||
def subtotal(self) -> Decimal:
|
||||
discount_amount = self.discount or Decimal('0')
|
||||
return self.quantity * self.unit_price * (1 - discount_amount)
|
||||
|
||||
class CreateOrderSchema(BaseModel):
|
||||
customer_email: str = Field(..., regex=r"^[\w\.-]+@[\w\.-]+\.\w+$")
|
||||
items: List[OrderItem] = Field(..., min_items=1, max_items=50)
|
||||
shipping_address: Address
|
||||
billing_address: Optional[Address] = None
|
||||
payment_method: PaymentMethod
|
||||
notes: Optional[str] = Field(None, max_length=500)
|
||||
coupon_code: Optional[str] = Field(None, regex="^[A-Z0-9]{4,12}$")
|
||||
|
||||
@validator('customer_email')
|
||||
def validate_email(cls, v):
|
||||
# Additional email validation
|
||||
if not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", v.lower()):
|
||||
raise ValueError('Invalid email format')
|
||||
return v.lower()
|
||||
|
||||
@validator('billing_address', always=True)
|
||||
def set_billing_address(cls, v, values):
|
||||
# Use shipping address if billing not provided
|
||||
return v or values.get('shipping_address')
|
||||
|
||||
@root_validator
|
||||
def validate_order(cls, values):
|
||||
items = values.get('items', [])
|
||||
|
||||
# Check for duplicate products
|
||||
product_ids = [item.product_id for item in items]
|
||||
if len(product_ids) != len(set(product_ids)):
|
||||
raise ValueError('Duplicate products in order')
|
||||
|
||||
# Calculate total
|
||||
total = sum(item.subtotal for item in items)
|
||||
if total <= 0:
|
||||
raise ValueError('Order total must be positive')
|
||||
|
||||
# Validate coupon if provided
|
||||
coupon = values.get('coupon_code')
|
||||
if coupon and not cls.validate_coupon(coupon):
|
||||
raise ValueError('Invalid coupon code')
|
||||
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def validate_coupon(code: str) -> bool:
|
||||
# Implement coupon validation logic
|
||||
valid_coupons = ['SAVE10', 'FREESHIP', 'WELCOME20']
|
||||
return code in valid_coupons
|
||||
|
||||
class UpdateOrderSchema(BaseModel):
|
||||
status: Optional[OrderStatus] = None
|
||||
shipping_address: Optional[Address] = None
|
||||
notes: Optional[str] = Field(None, max_length=500)
|
||||
tracking_number: Optional[str] = Field(None, regex=r"^[A-Z0-9]{10,30}$")
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
# FastAPI integration
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.post("/orders", response_model=Dict[str, Any])
|
||||
async def create_order(order: CreateOrderSchema):
|
||||
# Validation happens automatically
|
||||
order_dict = jsonable_encoder(order)
|
||||
# Process order
|
||||
return {"id": "ORD-123456", **order_dict}
|
||||
|
||||
@app.patch("/orders/{order_id}")
|
||||
async def update_order(
|
||||
order_id: str,
|
||||
updates: UpdateOrderSchema
|
||||
):
|
||||
# Only provided fields will be updated
|
||||
update_dict = updates.dict(exclude_unset=True)
|
||||
# Update order
|
||||
return {"id": order_id, **update_dict}
|
||||
|
||||
# Custom validation endpoint
|
||||
@app.post("/validate/order")
|
||||
async def validate_order(order: CreateOrderSchema):
|
||||
# Just validate without processing
|
||||
return {"valid": True, "data": order.dict()}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| "Invalid type" | Wrong data type provided | Check schema definition and input data |
|
||||
| "Required field missing" | Mandatory field not provided | Ensure all required fields are present |
|
||||
| "Validation failed" | Business rule violation | Review custom validators and constraints |
|
||||
| "Schema not found" | Referenced schema doesn't exist | Verify schema imports and definitions |
|
||||
| "Circular dependency" | Schema references itself | Refactor to break circular references |
|
||||
|
||||
## Configuration Options
|
||||
|
||||
**Validation Strategies**
|
||||
- `fail-fast`: Stop at first error (faster)
|
||||
- `collect-all`: Gather all errors (better UX)
|
||||
- `partial`: Allow partial validation for updates
|
||||
|
||||
**Type Coercion**
|
||||
- `strict`: No type conversion
|
||||
- `loose`: Attempt type conversion
|
||||
- `smart`: Context-aware conversion
|
||||
|
||||
## Best Practices
|
||||
|
||||
DO:
|
||||
- Create reusable base schemas for common patterns
|
||||
- Use descriptive error messages for custom validators
|
||||
- Generate TypeScript types from schemas
|
||||
- Version schemas with your API
|
||||
- Document all validation rules
|
||||
- Test edge cases and boundary conditions
|
||||
|
||||
DON'T:
|
||||
- Duplicate validation logic across layers
|
||||
- Use overly complex nested schemas
|
||||
- Ignore performance impact of complex validations
|
||||
- Mix validation with business logic
|
||||
- Trust client-side validation alone
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Compile schemas once at startup
|
||||
- Cache validated results for identical inputs
|
||||
- Use async validation for external checks
|
||||
- Limit regex complexity to prevent ReDoS attacks
|
||||
- Consider schema complexity for large payloads
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Sanitize error messages to prevent information leakage
|
||||
- Implement rate limiting on validation endpoints
|
||||
- Use strict type checking to prevent injection attacks
|
||||
- Validate file uploads separately with size limits
|
||||
- Never expose internal schema structure to clients
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `/api-response-validator` - Validate API responses
|
||||
- `/api-contract-generator` - Generate schemas from code
|
||||
- `/api-testing-framework` - Test with schema validation
|
||||
- `/api-documentation-generator` - Document schemas
|
||||
|
||||
## Version History
|
||||
|
||||
- v1.0.0 (2024-10): Initial implementation with Zod, Joi, and Pydantic support
|
||||
- Planned v1.1.0: Add support for Yup and JSON Schema generation
|
||||
Reference in New Issue
Block a user