first commit
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled

This commit is contained in:
2026-04-22 19:51:20 +07:00
commit 93d1b7c3d3
579 changed files with 99797 additions and 0 deletions
@@ -0,0 +1,747 @@
# API Controller Validation Guide
This guide explains how to configure validations in api-framework controllers, covering all available patterns, built-in validators, and best practices.
## Table of Contents
- [Overview](#overview)
- [Validation Patterns](#validation-patterns)
- [Object-Based Validation](#pattern-1-object-based-validation)
- [Function-Based Validation](#pattern-2-function-based-validation)
- [Validating Options (Query Parameters)](#validating-options-query-parameters)
- [Validating Data (Request Body)](#validating-data-request-body)
- [Built-in Global Validators](#built-in-global-validators)
- [Method-Specific Validation Behavior](#method-specific-validation-behavior)
- [Complete Examples](#complete-examples)
- [Error Handling](#error-handling)
- [Best Practices](#best-practices)
---
## Overview
The api-framework uses a **pipeline-based validation system** where validations run as the first processing stage:
1. **Validation** ← You are here
2. Input serialisation
3. Permissions
4. Query (controller execution)
5. Output serialisation
Validation ensures that:
- Required fields are present
- Values are in allowed lists
- Data types are correct (IDs, emails, slugs, etc.)
- Request structure is valid before processing
---
## Validation Patterns
### Pattern 1: Object-Based Validation
The most common pattern using configuration objects:
```javascript
browse: {
options: ['include', 'page', 'limit'],
validation: {
options: {
include: {
values: ['tags', 'authors'],
required: true
},
page: {
required: false
}
}
},
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
}
}
```
**When to use:**
- Standard field validation (required, allowed values)
- Most common case for API endpoints
---
### Pattern 2: Function-Based Validation
Complete control over validation logic:
```javascript
add: {
validation(frame) {
const {ValidationError} = require('@tryghost/errors');
if (!frame.data.posts || !frame.data.posts.length) {
return Promise.reject(new ValidationError({
message: 'No posts provided'
}));
}
const post = frame.data.posts[0];
if (!post.title || post.title.length < 3) {
return Promise.reject(new ValidationError({
message: 'Title must be at least 3 characters'
}));
}
return Promise.resolve();
},
permissions: true,
query(frame) {
return models.Post.add(frame.data.posts[0], frame.options);
}
}
```
**When to use:**
- Complex validation logic
- Cross-field validation
- Conditional validation rules
- Custom error messages
---
## Validating Options (Query Parameters)
Options are URL query parameters and route params. Define allowed options in the `options` array and configure validation rules.
### Required Fields
```javascript
browse: {
options: ['filter'],
validation: {
options: {
filter: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Post.findAll(frame.options);
}
}
```
### Allowed Values
Two equivalent syntaxes:
**Object notation:**
```javascript
validation: {
options: {
include: {
values: ['tags', 'authors', 'count.posts']
}
}
}
```
**Array shorthand:**
```javascript
validation: {
options: {
include: ['tags', 'authors', 'count.posts']
}
}
```
### Combined Rules
```javascript
validation: {
options: {
include: {
values: ['tags', 'authors'],
required: true
},
status: {
values: ['draft', 'published', 'scheduled'],
required: false
}
}
}
```
### Special Behavior: Include Parameter
The `include` parameter has special handling - invalid values are silently filtered instead of causing an error:
```javascript
// Request: ?include=tags,invalid_field,authors
// Result: frame.options.include = 'tags,authors'
```
This allows for graceful degradation when clients request unsupported includes.
---
## Validating Data (Request Body)
Data validation applies to request body content. The structure differs based on the HTTP method.
### For READ Operations
Data comes from query parameters:
```javascript
read: {
data: ['id', 'slug'],
validation: {
data: {
slug: {
values: ['featured', 'latest']
}
}
},
permissions: true,
query(frame) {
return models.Post.findOne(frame.data, frame.options);
}
}
```
### For ADD/EDIT Operations
Data comes from the request body with a root key:
```javascript
add: {
validation: {
data: {
title: {
required: true
},
status: {
required: false
}
}
},
permissions: true,
query(frame) {
return models.Post.add(frame.data.posts[0], frame.options);
}
}
```
**Request body structure:**
```json
{
"posts": [{
"title": "My Post",
"status": "draft"
}]
}
```
### Root Key Validation
For ADD/EDIT operations, the framework automatically validates:
1. Root key exists (e.g., `posts`, `users`)
2. Root key contains an array with at least one item
3. Required fields exist and are not null
---
## Built-in Global Validators
The framework automatically validates common field types using the `@tryghost/validator` package:
| Field Name | Validation Rule | Example Valid Values |
|------------|-----------------|---------------------|
| `id` | MongoDB ObjectId, `1`, or `me` | `507f1f77bcf86cd799439011`, `me` |
| `uuid` | UUID format | `550e8400-e29b-41d4-a716-446655440000` |
| `slug` | URL-safe slug | `my-post-title` |
| `email` | Email format | `user@example.com` |
| `page` | Numeric | `1`, `25` |
| `limit` | Numeric or `all` | `10`, `all` |
| `from` | Date format | `2024-01-15` |
| `to` | Date format | `2024-12-31` |
| `order` | Sort format | `created_at desc`, `title asc` |
| `columns` | Column list | `id,title,created_at` |
### Fields with No Validation
These fields skip validation by default:
- `filter`
- `context`
- `forUpdate`
- `transacting`
- `include`
- `formats`
- `name`
---
## Method-Specific Validation Behavior
Different HTTP methods have different validation behaviors:
### BROWSE / READ
- Validates `frame.data` against `apiConfig.data`
- Allows empty data
- Uses global validators for field types
### ADD
1. Validates root key exists in `frame.data`
2. Checks required fields are present
3. Checks required fields are not null
**Error examples:**
- `"No root key ('posts') provided."`
- `"Validation (FieldIsRequired) failed for title"`
- `"Validation (FieldIsInvalid) failed for title"` (when null)
### EDIT
1. Performs all ADD validations
2. Validates ID consistency between URL and body
```javascript
// URL: /posts/123
// Body: { "posts": [{ "id": "456", ... }] }
// Error: "Invalid id provided."
```
### Special Methods
These methods use specific validation behaviors:
- `changePassword()` - Uses ADD rules
- `resetPassword()` - Uses ADD rules
- `setup()` - Uses ADD rules
- `publish()` - Uses BROWSE rules
---
## Complete Examples
### Example 1: Simple Browse with Options
```javascript
module.exports = {
docName: 'posts',
browse: {
options: ['include', 'page', 'limit', 'filter', 'order'],
validation: {
options: {
include: ['tags', 'authors', 'count.posts'],
page: {
required: false
},
limit: {
required: false
}
}
},
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
}
}
};
```
### Example 2: Read with Data Validation
```javascript
module.exports = {
docName: 'posts',
read: {
options: ['include'],
data: ['id', 'slug'],
validation: {
options: {
include: ['tags', 'authors']
},
data: {
id: {
required: false
},
slug: {
required: false
}
}
},
permissions: true,
query(frame) {
return models.Post.findOne(frame.data, frame.options);
}
}
};
```
### Example 3: Add with Required Fields
```javascript
module.exports = {
docName: 'users',
add: {
validation: {
data: {
name: {
required: true
},
email: {
required: true
},
password: {
required: true
},
role: {
required: false
}
}
},
permissions: true,
query(frame) {
return models.User.add(frame.data.users[0], frame.options);
}
}
};
```
### Example 4: Custom Validation Function
```javascript
module.exports = {
docName: 'subscriptions',
add: {
validation(frame) {
const {ValidationError} = require('@tryghost/errors');
const subscription = frame.data.subscriptions?.[0];
if (!subscription) {
return Promise.reject(new ValidationError({
message: 'No subscription data provided'
}));
}
// Validate email format
if (!subscription.email || !subscription.email.includes('@')) {
return Promise.reject(new ValidationError({
message: 'Valid email address is required'
}));
}
// Validate plan
const validPlans = ['free', 'basic', 'premium'];
if (!validPlans.includes(subscription.plan)) {
return Promise.reject(new ValidationError({
message: `Plan must be one of: ${validPlans.join(', ')}`
}));
}
// Cross-field validation
if (subscription.plan !== 'free' && !subscription.payment_method) {
return Promise.reject(new ValidationError({
message: 'Payment method required for paid plans'
}));
}
return Promise.resolve();
},
permissions: true,
query(frame) {
return models.Subscription.add(frame.data.subscriptions[0], frame.options);
}
}
};
```
### Example 5: Edit with ID Consistency
```javascript
module.exports = {
docName: 'posts',
edit: {
options: ['id', 'include'],
validation: {
options: {
include: ['tags', 'authors']
},
data: {
title: {
required: false
},
status: {
values: ['draft', 'published', 'scheduled']
}
}
},
permissions: {
unsafeAttrs: ['status', 'author_id']
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options);
}
}
};
```
### Example 6: Complex Browse with Multiple Validations
```javascript
module.exports = {
docName: 'analytics',
browse: {
options: ['from', 'to', 'interval', 'metrics', 'dimensions'],
validation: {
options: {
from: {
required: true
},
to: {
required: true
},
interval: {
values: ['hour', 'day', 'week', 'month'],
required: false
},
metrics: {
values: ['pageviews', 'visitors', 'sessions', 'bounce_rate'],
required: true
},
dimensions: {
values: ['page', 'source', 'country', 'device'],
required: false
}
}
},
permissions: true,
query(frame) {
return analytics.query(frame.options);
}
}
};
```
---
## Error Handling
### Error Types
Validation errors use types from `@tryghost/errors`:
- **ValidationError** - Field validation failed
- **BadRequestError** - Malformed request structure
### Error Message Format
```javascript
// Missing required field
"Validation (FieldIsRequired) failed for title"
// Invalid value
"Validation (AllowedValues) failed for status"
// Field is null when required
"Validation (FieldIsInvalid) failed for title"
// Missing root key
"No root key ('posts') provided."
// ID mismatch
"Invalid id provided."
```
### Custom Error Messages
When using function-based validation:
```javascript
validation(frame) {
const {ValidationError} = require('@tryghost/errors');
if (!frame.data.email) {
return Promise.reject(new ValidationError({
message: 'Email address is required',
context: 'Please provide a valid email address to continue',
help: 'Check that the email field is included in your request'
}));
}
return Promise.resolve();
}
```
---
## Best Practices
### 1. Define All Allowed Options
Always explicitly list allowed options to prevent unexpected parameters:
```javascript
// Good - explicit allowed options
options: ['include', 'page', 'limit', 'filter'],
// Bad - no options defined (might allow anything)
// options: undefined
```
### 2. Use Built-in Validators
Let the framework handle common field types:
```javascript
// Good - framework validates automatically
options: ['id', 'email', 'slug']
// Unnecessary - these are validated by default
validation: {
options: {
id: { matches: /^[a-f\d]{24}$/ } // Already built-in
}
}
```
### 3. Mark Required Fields Explicitly
Be explicit about which fields are required:
```javascript
validation: {
data: {
title: { required: true },
slug: { required: false },
status: { required: false }
}
}
```
### 4. Use Array Shorthand for Simple Cases
When only validating allowed values:
```javascript
// Shorter and cleaner
validation: {
options: {
include: ['tags', 'authors'],
status: ['draft', 'published']
}
}
// Equivalent verbose form
validation: {
options: {
include: { values: ['tags', 'authors'] },
status: { values: ['draft', 'published'] }
}
}
```
### 5. Combine with Permissions
Validation runs before permissions, ensuring data structure is valid:
```javascript
edit: {
validation: {
data: {
author_id: { required: false }
}
},
permissions: {
unsafeAttrs: ['author_id'] // Validated first, then permission-checked
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options);
}
}
```
### 6. Use Custom Functions for Complex Logic
When validation rules depend on multiple fields or external state:
```javascript
validation(frame) {
// Date range validation
if (frame.options.from && frame.options.to) {
const from = new Date(frame.options.from);
const to = new Date(frame.options.to);
if (from > to) {
return Promise.reject(new ValidationError({
message: 'From date must be before to date'
}));
}
// Max 30 day range
const diffDays = (to - from) / (1000 * 60 * 60 * 24);
if (diffDays > 30) {
return Promise.reject(new ValidationError({
message: 'Date range cannot exceed 30 days'
}));
}
}
return Promise.resolve();
}
```
### 7. Provide Helpful Error Messages
Make errors actionable for API consumers:
```javascript
// Good - specific and actionable
"Status must be one of: draft, published, scheduled"
// Bad - vague
"Invalid status"
```
---
## Validation Flow Diagram
```
HTTP Request
Frame Creation
Frame Configuration (pick options/data)
┌─────────────────────────────┐
│ VALIDATION STAGE │
├─────────────────────────────┤
│ Is validation a function? │
│ ├─ Yes → Run custom logic │
│ └─ No → Framework validation│
│ ├─ Global validators │
│ ├─ Required fields │
│ ├─ Allowed values │
│ └─ Method-specific rules│
└─────────────────────────────┘
Input Serialisation
Permissions
Query Execution
Output Serialisation
HTTP Response
```