15 KiB
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
- Validation Patterns
- Validating Options (Query Parameters)
- Validating Data (Request Body)
- Built-in Global Validators
- Method-Specific Validation Behavior
- Complete Examples
- Error Handling
- Best Practices
Overview
The api-framework uses a pipeline-based validation system where validations run as the first processing stage:
- Validation ← You are here
- Input serialisation
- Permissions
- Query (controller execution)
- 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:
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:
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
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:
validation: {
options: {
include: {
values: ['tags', 'authors', 'count.posts']
}
}
}
Array shorthand:
validation: {
options: {
include: ['tags', 'authors', 'count.posts']
}
}
Combined Rules
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:
// 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:
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:
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:
{
"posts": [{
"title": "My Post",
"status": "draft"
}]
}
Root Key Validation
For ADD/EDIT operations, the framework automatically validates:
- Root key exists (e.g.,
posts,users) - Root key contains an array with at least one item
- 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:
filtercontextforUpdatetransactingincludeformatsname
Method-Specific Validation Behavior
Different HTTP methods have different validation behaviors:
BROWSE / READ
- Validates
frame.dataagainstapiConfig.data - Allows empty data
- Uses global validators for field types
ADD
- Validates root key exists in
frame.data - Checks required fields are present
- 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
- Performs all ADD validations
- Validates ID consistency between URL and body
// URL: /posts/123
// Body: { "posts": [{ "id": "456", ... }] }
// Error: "Invalid id provided."
Special Methods
These methods use specific validation behaviors:
changePassword()- Uses ADD rulesresetPassword()- Uses ADD rulessetup()- Uses ADD rulespublish()- Uses BROWSE rules
Complete Examples
Example 1: Simple Browse with Options
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
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
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
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
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
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
// 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:
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:
// 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:
// 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:
validation: {
data: {
title: { required: true },
slug: { required: false },
status: { required: false }
}
}
4. Use Array Shorthand for Simple Cases
When only validating allowed values:
// 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:
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:
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:
// 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