.agents/skills/add-admin-api-endpoint/validation.md
This guide explains how to configure validations in api-framework controllers, covering all available patterns, built-in validators, and best practices.
The api-framework uses a pipeline-based validation system where validations run as the first processing stage:
Validation ensures that:
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:
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:
Options are URL query parameters and route params. Define allowed options in the options array and configure validation rules.
browse: {
options: ['filter'],
validation: {
options: {
filter: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Post.findAll(frame.options);
}
}
Two equivalent syntaxes:
Object notation:
validation: {
options: {
include: {
values: ['tags', 'authors', 'count.posts']
}
}
}
Array shorthand:
validation: {
options: {
include: ['tags', 'authors', 'count.posts']
}
}
validation: {
options: {
include: {
values: ['tags', 'authors'],
required: true
},
status: {
values: ['draft', 'published', 'scheduled'],
required: false
}
}
}
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.
Data validation applies to request body content. The structure differs based on the HTTP method.
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);
}
}
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"
}]
}
For ADD/EDIT operations, the framework automatically validates:
posts, users)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 | [email protected] |
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 |
These fields skip validation by default:
filtercontextforUpdatetransactingincludeformatsnameDifferent HTTP methods have different validation behaviors:
frame.data against apiConfig.dataframe.dataError examples:
"No root key ('posts') provided.""Validation (FieldIsRequired) failed for title""Validation (FieldIsInvalid) failed for title" (when null)// URL: /posts/123
// Body: { "posts": [{ "id": "456", ... }] }
// Error: "Invalid id provided."
These methods use specific validation behaviors:
changePassword() - Uses ADD rulesresetPassword() - Uses ADD rulessetup() - Uses ADD rulespublish() - Uses BROWSE rulesmodule.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);
}
}
};
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);
}
}
};
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);
}
}
};
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);
}
}
};
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);
}
}
};
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);
}
}
};
Validation errors use types from @tryghost/errors:
// 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."
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();
}
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
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
}
}
Be explicit about which fields are required:
validation: {
data: {
title: { required: true },
slug: { required: false },
status: { required: false }
}
}
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'] }
}
}
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);
}
}
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();
}
Make errors actionable for API consumers:
// Good - specific and actionable
"Status must be one of: draft, published, scheduled"
// Bad - vague
"Invalid status"
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