This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: Add Admin API Endpoint
|
||||
description: Add a new endpoint or endpoints to Ghost's Admin API at `ghost/api/admin/**`.
|
||||
---
|
||||
|
||||
# Create Admin API Endpoint
|
||||
|
||||
## Instructions
|
||||
|
||||
1. If creating an endpoint for an entirely new resource, create a new endpoint file in `ghost/core/core/server/api/endpoints/`. Otherwise, locate the existing endpoint file in the same directory.
|
||||
2. The endpoint file should create a controller object using the JSDoc type from (@tryghost/api-framework).Controller, including at minimum a `docName` and a single endpoint definition, i.e. `browse`.
|
||||
3. Add routes for each endpoint to `ghost/core/core/server/web/api/endpoints/admin/routes.js`.
|
||||
4. Add basic `e2e-api` tests for the endpoint in `ghost/core/test/e2e-api/admin` to ensure the new endpoints function as expected.
|
||||
5. Run the tests and iterate until they pass: `cd ghost/core && pnpm test:single test/e2e-api/admin/{test-file-name}`.
|
||||
|
||||
## Reference
|
||||
For a detailed reference on Ghost's API framework and how to create API controllers, see [reference.md](reference.md).
|
||||
@@ -0,0 +1,711 @@
|
||||
# API Controller Permissions Guide
|
||||
|
||||
This guide explains how to configure permissions in api-framework controllers, covering all available patterns and best practices.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Permission Patterns](#permission-patterns)
|
||||
- [Boolean `true` - Default Permission Check](#pattern-1-boolean-true---default-permission-check)
|
||||
- [Boolean `false` - Skip Permissions](#pattern-2-boolean-false---skip-permissions)
|
||||
- [Function - Custom Permission Logic](#pattern-3-function---custom-permission-logic)
|
||||
- [Configuration Object - Default with Hooks](#pattern-4-configuration-object---default-with-hooks)
|
||||
- [The Frame Object](#the-frame-object)
|
||||
- [Configuration Object Properties](#configuration-object-properties)
|
||||
- [Complete Examples](#complete-examples)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The api-framework uses a **pipeline-based permission system** where permissions are handled as one of five request processing stages:
|
||||
|
||||
1. Input validation
|
||||
2. Input serialisation
|
||||
3. **Permissions** ← You are here
|
||||
4. Query (controller execution)
|
||||
5. Output serialisation
|
||||
|
||||
**Important**: Every controller method **MUST** explicitly define the `permissions` property. This is a security requirement that prevents accidental security holes and makes permission handling explicit.
|
||||
|
||||
```javascript
|
||||
// This will throw an IncorrectUsageError
|
||||
edit: {
|
||||
query(frame) {
|
||||
return models.Post.edit(frame.data, frame.options);
|
||||
}
|
||||
// Missing permissions property!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Permission Patterns
|
||||
|
||||
### Pattern 1: Boolean `true` - Default Permission Check
|
||||
|
||||
The most common pattern that delegates to the default permission handler.
|
||||
|
||||
```javascript
|
||||
edit: {
|
||||
headers: {
|
||||
cacheInvalidate: true
|
||||
},
|
||||
options: ['include'],
|
||||
validation: {
|
||||
options: {
|
||||
include: {
|
||||
required: true,
|
||||
values: ['tags']
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
return models.Post.edit(frame.data, frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Standard CRUD operations
|
||||
- When the default permission handler meets your needs
|
||||
- Most common case for authenticated endpoints
|
||||
|
||||
#### How the Default Permission Handler Works
|
||||
|
||||
When you set `permissions: true`, the framework delegates to the default permission handler at `ghost/core/core/server/api/endpoints/utils/permissions.js`. Here's what happens:
|
||||
|
||||
1. **Singular Name Derivation**: The handler converts the `docName` to singular form:
|
||||
- `posts` → `post`
|
||||
- `automated_emails` → `automated_email`
|
||||
- `categories` → `category` (handles `ies` → `y`)
|
||||
|
||||
2. **Permission Check**: It calls the permissions service:
|
||||
```javascript
|
||||
permissions.canThis(frame.options.context)[method][singular](identifier, unsafeAttrs)
|
||||
```
|
||||
|
||||
For example, with `docName: 'posts'` and method `edit`:
|
||||
```javascript
|
||||
permissions.canThis(context).edit.post(postId, unsafeAttrs)
|
||||
```
|
||||
|
||||
3. **Database Lookup**: The permissions service checks the `permissions` and `permissions_roles` tables:
|
||||
- Looks for a permission with `action_type` matching the method (e.g., `edit`)
|
||||
- And `object_type` matching the singular docName (e.g., `post`)
|
||||
- Verifies the user's role has that permission assigned
|
||||
|
||||
#### Required Database Setup
|
||||
|
||||
For the default handler to work, you must have:
|
||||
|
||||
1. **Permission records** in the `permissions` table:
|
||||
```sql
|
||||
INSERT INTO permissions (name, action_type, object_type) VALUES
|
||||
('Browse posts', 'browse', 'post'),
|
||||
('Read posts', 'read', 'post'),
|
||||
('Edit posts', 'edit', 'post'),
|
||||
('Add posts', 'add', 'post'),
|
||||
('Delete posts', 'destroy', 'post');
|
||||
```
|
||||
|
||||
2. **Role-permission mappings** in `permissions_roles` linking permissions to roles like Administrator, Editor, etc.
|
||||
|
||||
These are typically added via:
|
||||
- Initial fixtures in `ghost/core/core/server/data/schema/fixtures/fixtures.json`
|
||||
- Database migrations using `addPermissionWithRoles()` from `ghost/core/core/server/data/migrations/utils/permissions.js`
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Boolean `false` - Skip Permissions
|
||||
|
||||
Completely bypasses the permissions stage.
|
||||
|
||||
```javascript
|
||||
browse: {
|
||||
options: ['page', 'limit'],
|
||||
permissions: false,
|
||||
query(frame) {
|
||||
return models.PublicResource.findAll(frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Public endpoints that don't require authentication
|
||||
- Health check or status endpoints
|
||||
- Resources that should be accessible to everyone
|
||||
|
||||
**Warning**: Use with caution. Only disable permissions when you're certain the endpoint should be publicly accessible.
|
||||
|
||||
---
|
||||
|
||||
### Pattern 3: Function - Custom Permission Logic
|
||||
|
||||
Allows complete control over permission validation.
|
||||
|
||||
```javascript
|
||||
delete: {
|
||||
options: ['id'],
|
||||
permissions: async function(frame) {
|
||||
// Ensure user is authenticated
|
||||
if (!frame.user || !frame.user.id) {
|
||||
const UnauthorizedError = require('@tryghost/errors').UnauthorizedError;
|
||||
return Promise.reject(new UnauthorizedError({
|
||||
message: 'You must be logged in to perform this action'
|
||||
}));
|
||||
}
|
||||
|
||||
// Only the owner or an admin can delete
|
||||
const resource = await models.Resource.findOne({id: frame.options.id});
|
||||
|
||||
if (resource.get('author_id') !== frame.user.id && frame.user.role !== 'admin') {
|
||||
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
|
||||
return Promise.reject(new NoPermissionError({
|
||||
message: 'You do not have permission to delete this resource'
|
||||
}));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
query(frame) {
|
||||
return models.Resource.destroy(frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Complex permission logic that varies by resource
|
||||
- Owner-based permissions
|
||||
- Role-based access control beyond the default handler
|
||||
- When you need to query the database for permission decisions
|
||||
|
||||
---
|
||||
|
||||
### Pattern 4: Configuration Object - Default with Hooks
|
||||
|
||||
Combines default permission handling with configuration options and hooks.
|
||||
|
||||
```javascript
|
||||
edit: {
|
||||
options: ['include'],
|
||||
permissions: {
|
||||
unsafeAttrs: ['author', 'status'],
|
||||
before: async function(frame) {
|
||||
// Load additional user data needed for permission checks
|
||||
frame.user.permissions = await loadUserPermissions(frame.user.id);
|
||||
}
|
||||
},
|
||||
query(frame) {
|
||||
return models.Post.edit(frame.data, frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Default permission handler is sufficient but needs configuration
|
||||
- You have attributes that require special permission handling
|
||||
- You need to prepare data before permission checks run
|
||||
|
||||
---
|
||||
|
||||
## The Frame Object
|
||||
|
||||
Permission handlers receive a `frame` object containing complete request context:
|
||||
|
||||
```javascript
|
||||
Frame {
|
||||
// Request data
|
||||
original: {}, // Original untransformed input
|
||||
options: {}, // Query/URL parameters
|
||||
data: {}, // Request body
|
||||
|
||||
// User context
|
||||
user: {}, // Logged-in user object
|
||||
|
||||
// File uploads
|
||||
file: {}, // Single uploaded file
|
||||
files: [], // Multiple uploaded files
|
||||
|
||||
// API context
|
||||
apiType: String, // 'content' or 'admin'
|
||||
docName: String, // Endpoint name (e.g., 'posts')
|
||||
method: String, // Method name (e.g., 'browse', 'add', 'edit')
|
||||
|
||||
// HTTP context (added by HTTP wrapper)
|
||||
context: {
|
||||
api_key: {}, // API key information
|
||||
user: userId, // User ID or null
|
||||
integration: {}, // Integration details
|
||||
member: {} // Member information or null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Object Properties
|
||||
|
||||
When using Pattern 4, these properties are available:
|
||||
|
||||
### `unsafeAttrs` (Array)
|
||||
|
||||
Specifies attributes that require special permission handling.
|
||||
|
||||
```javascript
|
||||
permissions: {
|
||||
unsafeAttrs: ['author', 'visibility', 'status']
|
||||
}
|
||||
```
|
||||
|
||||
These attributes are passed to the permission handler for additional validation. Use this for fields that only certain users should be able to modify (e.g., only admins can change the author of a post).
|
||||
|
||||
### `before` (Function)
|
||||
|
||||
A hook that runs before the default permission handler.
|
||||
|
||||
```javascript
|
||||
permissions: {
|
||||
before: async function(frame) {
|
||||
// Prepare data needed for permission checks
|
||||
const membership = await loadMembership(frame.user.id);
|
||||
frame.user.membershipLevel = membership.level;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Example 1: Public Browse Endpoint
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
docName: 'articles',
|
||||
|
||||
browse: {
|
||||
options: ['page', 'limit', 'filter'],
|
||||
validation: {
|
||||
options: {
|
||||
limit: {
|
||||
values: [10, 25, 50, 100]
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: false,
|
||||
query(frame) {
|
||||
return models.Article.findPage(frame.options);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Example 2: Authenticated CRUD Controller
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
docName: 'posts',
|
||||
|
||||
browse: {
|
||||
options: ['include', 'page', 'limit', 'filter', 'order'],
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
return models.Post.findPage(frame.options);
|
||||
}
|
||||
},
|
||||
|
||||
read: {
|
||||
options: ['include'],
|
||||
data: ['id', 'slug'],
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
return models.Post.findOne(frame.data, frame.options);
|
||||
}
|
||||
},
|
||||
|
||||
add: {
|
||||
headers: {
|
||||
cacheInvalidate: true
|
||||
},
|
||||
options: ['include'],
|
||||
permissions: {
|
||||
unsafeAttrs: ['author_id']
|
||||
},
|
||||
query(frame) {
|
||||
return models.Post.add(frame.data.posts[0], frame.options);
|
||||
}
|
||||
},
|
||||
|
||||
edit: {
|
||||
headers: {
|
||||
cacheInvalidate: true
|
||||
},
|
||||
options: ['include', 'id'],
|
||||
permissions: {
|
||||
unsafeAttrs: ['author_id', 'status']
|
||||
},
|
||||
query(frame) {
|
||||
return models.Post.edit(frame.data.posts[0], frame.options);
|
||||
}
|
||||
},
|
||||
|
||||
destroy: {
|
||||
headers: {
|
||||
cacheInvalidate: true
|
||||
},
|
||||
options: ['id'],
|
||||
permissions: true,
|
||||
statusCode: 204,
|
||||
query(frame) {
|
||||
return models.Post.destroy(frame.options);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Example 3: Owner-Based Permissions
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
docName: 'user_settings',
|
||||
|
||||
read: {
|
||||
options: ['user_id'],
|
||||
permissions: async function(frame) {
|
||||
// Users can only read their own settings
|
||||
if (frame.options.user_id !== frame.user.id) {
|
||||
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
|
||||
return Promise.reject(new NoPermissionError({
|
||||
message: 'You can only view your own settings'
|
||||
}));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
query(frame) {
|
||||
return models.UserSetting.findOne({user_id: frame.options.user_id});
|
||||
}
|
||||
},
|
||||
|
||||
edit: {
|
||||
options: ['user_id'],
|
||||
permissions: async function(frame) {
|
||||
// Users can only edit their own settings
|
||||
if (frame.options.user_id !== frame.user.id) {
|
||||
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
|
||||
return Promise.reject(new NoPermissionError({
|
||||
message: 'You can only edit your own settings'
|
||||
}));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
query(frame) {
|
||||
return models.UserSetting.edit(frame.data, frame.options);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Example 4: Role-Based Access Control
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
docName: 'admin_settings',
|
||||
|
||||
browse: {
|
||||
permissions: async function(frame) {
|
||||
const allowedRoles = ['Owner', 'Administrator'];
|
||||
|
||||
if (!frame.user || !allowedRoles.includes(frame.user.role)) {
|
||||
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
|
||||
return Promise.reject(new NoPermissionError({
|
||||
message: 'Only administrators can access these settings'
|
||||
}));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
query(frame) {
|
||||
return models.AdminSetting.findAll();
|
||||
}
|
||||
},
|
||||
|
||||
edit: {
|
||||
permissions: async function(frame) {
|
||||
// Only the owner can edit admin settings
|
||||
if (!frame.user || frame.user.role !== 'Owner') {
|
||||
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
|
||||
return Promise.reject(new NoPermissionError({
|
||||
message: 'Only the site owner can modify these settings'
|
||||
}));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
query(frame) {
|
||||
return models.AdminSetting.edit(frame.data, frame.options);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Example 5: Permission with Data Preparation
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
docName: 'premium_content',
|
||||
|
||||
read: {
|
||||
options: ['id'],
|
||||
permissions: {
|
||||
before: async function(frame) {
|
||||
// Load user's subscription status
|
||||
if (frame.user) {
|
||||
const subscription = await models.Subscription.findOne({
|
||||
user_id: frame.user.id
|
||||
});
|
||||
frame.user.subscription = subscription;
|
||||
}
|
||||
}
|
||||
},
|
||||
async query(frame) {
|
||||
// The query can now use frame.user.subscription
|
||||
const content = await models.Content.findOne({id: frame.options.id});
|
||||
|
||||
if (content.get('premium') && !frame.user?.subscription?.active) {
|
||||
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
|
||||
throw new NoPermissionError({
|
||||
message: 'Premium subscription required'
|
||||
});
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Define Permissions Explicitly
|
||||
|
||||
```javascript
|
||||
// Good - explicit about being public
|
||||
permissions: false
|
||||
|
||||
// Good - explicit about requiring auth
|
||||
permissions: true
|
||||
|
||||
// Bad - missing permissions (will throw error)
|
||||
// permissions: undefined
|
||||
```
|
||||
|
||||
### 2. Use the Appropriate Pattern
|
||||
|
||||
| Scenario | Pattern |
|
||||
|----------|---------|
|
||||
| Public endpoint | `permissions: false` |
|
||||
| Standard authenticated CRUD | `permissions: true` |
|
||||
| Need unsafe attrs tracking | `permissions: { unsafeAttrs: [...] }` |
|
||||
| Complex custom logic | `permissions: async function(frame) {...}` |
|
||||
| Need pre-processing | `permissions: { before: async function(frame) {...} }` |
|
||||
|
||||
### 3. Keep Permission Logic Focused
|
||||
|
||||
Permission functions should only check permissions, not perform business logic:
|
||||
|
||||
```javascript
|
||||
// Good - only checks permissions
|
||||
permissions: async function(frame) {
|
||||
if (!frame.user || frame.user.role !== 'admin') {
|
||||
throw new NoPermissionError();
|
||||
}
|
||||
}
|
||||
|
||||
// Bad - mixes permission check with business logic
|
||||
permissions: async function(frame) {
|
||||
if (!frame.user) throw new NoPermissionError();
|
||||
|
||||
// Don't do this in permissions!
|
||||
frame.data.processed = true;
|
||||
await sendNotification(frame.user);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use Meaningful Error Messages
|
||||
|
||||
```javascript
|
||||
permissions: async function(frame) {
|
||||
if (!frame.user) {
|
||||
throw new UnauthorizedError({
|
||||
message: 'Please log in to access this resource'
|
||||
});
|
||||
}
|
||||
|
||||
if (frame.user.role !== 'admin') {
|
||||
throw new NoPermissionError({
|
||||
message: 'Administrator access required for this operation'
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Validate Resource Ownership
|
||||
|
||||
When resources belong to specific users, always verify ownership:
|
||||
|
||||
```javascript
|
||||
permissions: async function(frame) {
|
||||
const resource = await models.Resource.findOne({id: frame.options.id});
|
||||
|
||||
if (!resource) {
|
||||
throw new NotFoundError({message: 'Resource not found'});
|
||||
}
|
||||
|
||||
const isOwner = resource.get('user_id') === frame.user.id;
|
||||
const isAdmin = frame.user.role === 'admin';
|
||||
|
||||
if (!isOwner && !isAdmin) {
|
||||
throw new NoPermissionError({
|
||||
message: 'You do not have permission to access this resource'
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Use `unsafeAttrs` for Sensitive Fields
|
||||
|
||||
Mark fields that require elevated permissions:
|
||||
|
||||
```javascript
|
||||
permissions: {
|
||||
unsafeAttrs: [
|
||||
'author_id', // Only admins should change authorship
|
||||
'status', // Publishing requires special permission
|
||||
'visibility', // Changing visibility is restricted
|
||||
'featured' // Only editors can feature content
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Types
|
||||
|
||||
Use appropriate error types from `@tryghost/errors`:
|
||||
|
||||
- **UnauthorizedError** - User is not authenticated
|
||||
- **NoPermissionError** - User is authenticated but lacks permission
|
||||
- **NotFoundError** - Resource doesn't exist (use carefully to avoid information leakage)
|
||||
- **ValidationError** - Input validation failed
|
||||
|
||||
```javascript
|
||||
const {
|
||||
UnauthorizedError,
|
||||
NoPermissionError,
|
||||
NotFoundError
|
||||
} = require('@tryghost/errors');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding Permissions via Migrations
|
||||
|
||||
When creating a new API endpoint that uses the default permission handler (`permissions: true`), you need to add permissions to the database. Ghost provides utilities to make this easy.
|
||||
|
||||
### Migration Utilities
|
||||
|
||||
Import the permission utilities from `ghost/core/core/server/data/migrations/utils`:
|
||||
|
||||
```javascript
|
||||
const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils');
|
||||
```
|
||||
|
||||
### Example: Adding CRUD Permissions for a New Resource
|
||||
|
||||
```javascript
|
||||
// ghost/core/core/server/data/migrations/versions/X.X/YYYY-MM-DD-HH-MM-SS-add-myresource-permissions.js
|
||||
|
||||
const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils');
|
||||
|
||||
module.exports = combineTransactionalMigrations(
|
||||
addPermissionWithRoles({
|
||||
name: 'Browse my resources',
|
||||
action: 'browse',
|
||||
object: 'my_resource' // Singular form of docName
|
||||
}, [
|
||||
'Administrator',
|
||||
'Admin Integration'
|
||||
]),
|
||||
addPermissionWithRoles({
|
||||
name: 'Read my resources',
|
||||
action: 'read',
|
||||
object: 'my_resource'
|
||||
}, [
|
||||
'Administrator',
|
||||
'Admin Integration'
|
||||
]),
|
||||
addPermissionWithRoles({
|
||||
name: 'Edit my resources',
|
||||
action: 'edit',
|
||||
object: 'my_resource'
|
||||
}, [
|
||||
'Administrator',
|
||||
'Admin Integration'
|
||||
]),
|
||||
addPermissionWithRoles({
|
||||
name: 'Add my resources',
|
||||
action: 'add',
|
||||
object: 'my_resource'
|
||||
}, [
|
||||
'Administrator',
|
||||
'Admin Integration'
|
||||
]),
|
||||
addPermissionWithRoles({
|
||||
name: 'Delete my resources',
|
||||
action: 'destroy',
|
||||
object: 'my_resource'
|
||||
}, [
|
||||
'Administrator',
|
||||
'Admin Integration'
|
||||
])
|
||||
);
|
||||
```
|
||||
|
||||
### Available Roles
|
||||
|
||||
Common roles you can assign permissions to:
|
||||
|
||||
- **Administrator** - Full admin access
|
||||
- **Admin Integration** - API integrations with admin scope
|
||||
- **Editor** - Can manage all content
|
||||
- **Author** - Can manage own content
|
||||
- **Contributor** - Can create drafts only
|
||||
- **Owner** - Site owner (inherits all Administrator permissions)
|
||||
|
||||
### Permission Naming Conventions
|
||||
|
||||
- **name**: Human-readable, e.g., `'Browse automated emails'`
|
||||
- **action**: The API method - `browse`, `read`, `edit`, `add`, `destroy`
|
||||
- **object**: Singular form of `docName` - `automated_email` (not `automated_emails`)
|
||||
|
||||
### Restricting to Administrators Only
|
||||
|
||||
To make an endpoint accessible only to administrators (not editors, authors, etc.), only assign permissions to:
|
||||
- `Administrator`
|
||||
- `Admin Integration`
|
||||
|
||||
```javascript
|
||||
addPermissionWithRoles({
|
||||
name: 'Browse sensitive data',
|
||||
action: 'browse',
|
||||
object: 'sensitive_data'
|
||||
}, [
|
||||
'Administrator',
|
||||
'Admin Integration'
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,633 @@
|
||||
# Ghost API Framework Reference
|
||||
|
||||
## Overview
|
||||
|
||||
The API framework is a pipeline-based system that processes HTTP requests through a series of stages before executing the controller logic. It provides consistent validation, serialization, and permission handling across all API endpoints.
|
||||
|
||||
## Request Flow
|
||||
|
||||
Each request goes through these stages in order:
|
||||
|
||||
1. **Input Validation** - Validates query params, URL params, and request body
|
||||
2. **Input Serialization** - Transforms incoming data (e.g., maps `include` to `withRelated`)
|
||||
3. **Permissions** - Checks if the user/API key has access to the resource
|
||||
4. **Query** - Executes the actual business logic (your controller code)
|
||||
5. **Output Serialization** - Formats the response for the client
|
||||
|
||||
## The Frame Object
|
||||
|
||||
The `Frame` class holds all request information and is passed through each stage. Each stage can modify it by reference.
|
||||
|
||||
### Frame Structure
|
||||
|
||||
```javascript
|
||||
{
|
||||
original: Object, // Original input (for debugging)
|
||||
options: Object, // Query params, URL params, context, custom options
|
||||
data: Object, // Request body, or query/URL params if configured via `data`
|
||||
user: Object, // Logged in user object
|
||||
file: Object, // Single uploaded file
|
||||
files: Array, // Multiple uploaded files
|
||||
apiType: String, // 'content' or 'admin'
|
||||
docName: String, // Endpoint name (e.g., 'posts')
|
||||
method: String, // Method name (e.g., 'browse', 'read', 'add', 'edit')
|
||||
response: Object // Set by output serialization
|
||||
}
|
||||
```
|
||||
|
||||
### Frame Example
|
||||
|
||||
```javascript
|
||||
{
|
||||
original: {
|
||||
include: 'tags,authors'
|
||||
},
|
||||
options: {
|
||||
withRelated: ['tags', 'authors'],
|
||||
context: { user: '123' }
|
||||
},
|
||||
data: {
|
||||
posts: [{ title: 'My Post' }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Controller Structure
|
||||
|
||||
Controllers are objects with a `docName` property and method configurations.
|
||||
|
||||
### Basic Structure
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
docName: 'posts', // Required: endpoint name
|
||||
|
||||
browse: {
|
||||
headers: {},
|
||||
options: [],
|
||||
data: [],
|
||||
validation: {},
|
||||
permissions: true,
|
||||
query(frame) {}
|
||||
},
|
||||
|
||||
read: { /* ... */ },
|
||||
add: { /* ... */ },
|
||||
edit: { /* ... */ },
|
||||
destroy: { /* ... */ }
|
||||
};
|
||||
```
|
||||
|
||||
## Controller Method Properties
|
||||
|
||||
### `headers` (Object)
|
||||
|
||||
Configure HTTP response headers.
|
||||
|
||||
```javascript
|
||||
headers: {
|
||||
// Invalidate cache after mutation
|
||||
cacheInvalidate: true,
|
||||
// Or with specific path
|
||||
cacheInvalidate: { value: '/posts/*' },
|
||||
|
||||
// File disposition for downloads
|
||||
disposition: {
|
||||
type: 'csv', // 'csv', 'json', 'yaml', or 'file'
|
||||
value: 'export.csv' // Can also be a function
|
||||
},
|
||||
|
||||
// Location header (auto-generated for 'add' methods)
|
||||
location: false // Disable auto-generation
|
||||
}
|
||||
```
|
||||
|
||||
### `options` (Array)
|
||||
|
||||
Allowed query/URL parameters that go into `frame.options`.
|
||||
|
||||
```javascript
|
||||
options: ['include', 'filter', 'page', 'limit', 'order']
|
||||
```
|
||||
|
||||
Can also be a function:
|
||||
```javascript
|
||||
options: (frame) => {
|
||||
return frame.apiType === 'content'
|
||||
? ['include']
|
||||
: ['include', 'filter'];
|
||||
}
|
||||
```
|
||||
|
||||
### `data` (Array)
|
||||
|
||||
Parameters that go into `frame.data` instead of `frame.options`. Useful for READ requests where the model expects `findOne(data, options)`.
|
||||
|
||||
```javascript
|
||||
data: ['id', 'slug', 'email']
|
||||
```
|
||||
|
||||
### `validation` (Object | Function)
|
||||
|
||||
Configure input validation. The framework validates against global validators automatically.
|
||||
|
||||
```javascript
|
||||
validation: {
|
||||
options: {
|
||||
include: {
|
||||
required: true,
|
||||
values: ['tags', 'authors', 'tiers']
|
||||
},
|
||||
filter: {
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data: {
|
||||
slug: {
|
||||
required: true,
|
||||
values: ['specific-slug'] // Restrict to specific values
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Global validators** (automatically applied when parameters are present):
|
||||
- `id` - Must match `/^[a-f\d]{24}$|^1$|me/i`
|
||||
- `page` - Must be a number
|
||||
- `limit` - Must be a number or 'all'
|
||||
- `uuid` - Must be a valid UUID
|
||||
- `slug` - Must be a valid slug
|
||||
- `email` - Must be a valid email
|
||||
- `order` - Must match `/^[a-z0-9_,. ]+$/i`
|
||||
|
||||
For custom validation, use a function:
|
||||
```javascript
|
||||
validation(frame) {
|
||||
if (!frame.data.posts[0].title) {
|
||||
return Promise.reject(new errors.ValidationError({
|
||||
message: 'Title is required'
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `permissions` (Boolean | Object | Function)
|
||||
|
||||
**Required field** - you must always specify permissions to avoid security holes.
|
||||
|
||||
```javascript
|
||||
// Use default permission handling
|
||||
permissions: true,
|
||||
|
||||
// Skip permission checking (use sparingly!)
|
||||
permissions: false,
|
||||
|
||||
// With configuration
|
||||
permissions: {
|
||||
// Attributes that require elevated permissions
|
||||
unsafeAttrs: ['status', 'authors'],
|
||||
|
||||
// Run code before permission check
|
||||
before(frame) {
|
||||
// Modify frame or do pre-checks
|
||||
},
|
||||
|
||||
// Specify which resource type to check against
|
||||
docName: 'posts',
|
||||
|
||||
// Specify different method for permission check
|
||||
method: 'browse'
|
||||
}
|
||||
|
||||
// Custom permission handling
|
||||
permissions: async function(frame) {
|
||||
const hasAccess = await checkCustomAccess(frame);
|
||||
if (!hasAccess) {
|
||||
return Promise.reject(new errors.NoPermissionError());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `query` (Function) - Required
|
||||
|
||||
The main business logic. Returns the API response.
|
||||
|
||||
```javascript
|
||||
query(frame) {
|
||||
// Access validated options
|
||||
const { include, filter, page, limit } = frame.options;
|
||||
|
||||
// Access request body
|
||||
const postData = frame.data.posts[0];
|
||||
|
||||
// Access context
|
||||
const userId = frame.options.context.user;
|
||||
|
||||
// Return model response
|
||||
return models.Post.findPage(frame.options);
|
||||
}
|
||||
```
|
||||
|
||||
### `statusCode` (Number | Function)
|
||||
|
||||
Set the HTTP status code. Defaults to 200.
|
||||
|
||||
```javascript
|
||||
// Fixed status code
|
||||
statusCode: 201,
|
||||
|
||||
// Dynamic based on result
|
||||
statusCode: (result) => {
|
||||
return result.posts.length ? 200 : 204;
|
||||
}
|
||||
```
|
||||
|
||||
### `response` (Object)
|
||||
|
||||
Configure response format.
|
||||
|
||||
```javascript
|
||||
response: {
|
||||
format: 'plain' // Send as plain text instead of JSON
|
||||
}
|
||||
```
|
||||
|
||||
### `cache` (Object)
|
||||
|
||||
Enable endpoint-level caching.
|
||||
|
||||
```javascript
|
||||
cache: {
|
||||
async get(cacheKey, fallback) {
|
||||
const cached = await redis.get(cacheKey);
|
||||
return cached || await fallback();
|
||||
},
|
||||
async set(cacheKey, response) {
|
||||
await redis.set(cacheKey, response, 'EX', 3600);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `generateCacheKeyData` (Function)
|
||||
|
||||
Customize cache key generation.
|
||||
|
||||
```javascript
|
||||
generateCacheKeyData(frame) {
|
||||
// Default uses frame.options
|
||||
return {
|
||||
...frame.options,
|
||||
customKey: 'value'
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Controller Examples
|
||||
|
||||
### Browse Endpoint (List)
|
||||
|
||||
```javascript
|
||||
browse: {
|
||||
headers: {
|
||||
cacheInvalidate: false
|
||||
},
|
||||
options: [
|
||||
'include',
|
||||
'filter',
|
||||
'fields',
|
||||
'formats',
|
||||
'page',
|
||||
'limit',
|
||||
'order'
|
||||
],
|
||||
validation: {
|
||||
options: {
|
||||
include: {
|
||||
values: ['tags', 'authors', 'tiers']
|
||||
},
|
||||
formats: {
|
||||
values: ['html', 'plaintext', 'mobiledoc']
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
return models.Post.findPage(frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Read Endpoint (Single)
|
||||
|
||||
```javascript
|
||||
read: {
|
||||
headers: {
|
||||
cacheInvalidate: false
|
||||
},
|
||||
options: ['include', 'fields', 'formats'],
|
||||
data: ['id', 'slug'],
|
||||
validation: {
|
||||
options: {
|
||||
include: {
|
||||
values: ['tags', 'authors']
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
return models.Post.findOne(frame.data, frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Add Endpoint (Create)
|
||||
|
||||
```javascript
|
||||
add: {
|
||||
headers: {
|
||||
cacheInvalidate: true
|
||||
},
|
||||
options: ['include'],
|
||||
validation: {
|
||||
options: {
|
||||
include: {
|
||||
values: ['tags', 'authors']
|
||||
}
|
||||
},
|
||||
data: {
|
||||
title: { required: true }
|
||||
}
|
||||
},
|
||||
permissions: {
|
||||
unsafeAttrs: ['status', 'authors']
|
||||
},
|
||||
statusCode: 201,
|
||||
query(frame) {
|
||||
return models.Post.add(frame.data.posts[0], frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Edit Endpoint (Update)
|
||||
|
||||
```javascript
|
||||
edit: {
|
||||
headers: {
|
||||
cacheInvalidate: true
|
||||
},
|
||||
options: ['include', 'id'],
|
||||
validation: {
|
||||
options: {
|
||||
include: {
|
||||
values: ['tags', 'authors']
|
||||
},
|
||||
id: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: {
|
||||
unsafeAttrs: ['status', 'authors']
|
||||
},
|
||||
query(frame) {
|
||||
return models.Post.edit(frame.data.posts[0], frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Destroy Endpoint (Delete)
|
||||
|
||||
```javascript
|
||||
destroy: {
|
||||
headers: {
|
||||
cacheInvalidate: true
|
||||
},
|
||||
options: ['id'],
|
||||
validation: {
|
||||
options: {
|
||||
id: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: true,
|
||||
statusCode: 204,
|
||||
query(frame) {
|
||||
return models.Post.destroy(frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File Upload Endpoint
|
||||
|
||||
```javascript
|
||||
uploadImage: {
|
||||
headers: {
|
||||
cacheInvalidate: false
|
||||
},
|
||||
permissions: {
|
||||
method: 'add'
|
||||
},
|
||||
query(frame) {
|
||||
// Access uploaded file
|
||||
const file = frame.file;
|
||||
|
||||
return imageService.upload({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
type: file.type
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CSV Export Endpoint
|
||||
|
||||
```javascript
|
||||
exportCSV: {
|
||||
headers: {
|
||||
disposition: {
|
||||
type: 'csv',
|
||||
value() {
|
||||
return `members.${new Date().toISOString()}.csv`;
|
||||
}
|
||||
}
|
||||
},
|
||||
options: ['filter'],
|
||||
permissions: true,
|
||||
response: {
|
||||
format: 'plain'
|
||||
},
|
||||
query(frame) {
|
||||
return membersService.export(frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using the Framework
|
||||
|
||||
### HTTP Wrapper
|
||||
|
||||
Wrap controllers for Express routes:
|
||||
|
||||
```javascript
|
||||
const {http} = require('@tryghost/api-framework');
|
||||
|
||||
// In routes
|
||||
router.get('/posts', http(api.posts.browse));
|
||||
router.get('/posts/:id', http(api.posts.read));
|
||||
router.post('/posts', http(api.posts.add));
|
||||
router.put('/posts/:id', http(api.posts.edit));
|
||||
router.delete('/posts/:id', http(api.posts.destroy));
|
||||
```
|
||||
|
||||
### Internal API Calls
|
||||
|
||||
Call controllers programmatically:
|
||||
|
||||
```javascript
|
||||
// With data and options
|
||||
const result = await api.posts.add(
|
||||
{ posts: [{ title: 'New Post' }] }, // data
|
||||
{ context: { user: userId } } // options
|
||||
);
|
||||
|
||||
// Options only
|
||||
const posts = await api.posts.browse({
|
||||
filter: 'status:published',
|
||||
include: 'tags',
|
||||
context: { user: userId }
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Validators
|
||||
|
||||
Create endpoint-specific validators in the API utils:
|
||||
|
||||
```javascript
|
||||
// In api/utils/validators/input/posts.js
|
||||
module.exports = {
|
||||
add(apiConfig, frame) {
|
||||
// Custom validation for posts.add
|
||||
const post = frame.data.posts[0];
|
||||
if (post.status === 'published' && !post.title) {
|
||||
return Promise.reject(new errors.ValidationError({
|
||||
message: 'Published posts must have a title'
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Custom Serializers
|
||||
|
||||
Create input/output serializers:
|
||||
|
||||
```javascript
|
||||
// Input serializer
|
||||
module.exports = {
|
||||
all(apiConfig, frame) {
|
||||
// Transform include to withRelated
|
||||
if (frame.options.include) {
|
||||
frame.options.withRelated = frame.options.include.split(',');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Output serializer
|
||||
module.exports = {
|
||||
posts: {
|
||||
browse(response, apiConfig, frame) {
|
||||
// Transform model response to API response
|
||||
frame.response = {
|
||||
posts: response.data.map(post => serializePost(post)),
|
||||
meta: {
|
||||
pagination: response.meta.pagination
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Checking User Context
|
||||
|
||||
```javascript
|
||||
query(frame) {
|
||||
const isAdmin = frame.options.context.user;
|
||||
const isIntegration = frame.options.context.integration;
|
||||
const isMember = frame.options.context.member;
|
||||
|
||||
if (isAdmin) {
|
||||
return models.Post.findPage(frame.options);
|
||||
} else {
|
||||
frame.options.filter = 'status:published';
|
||||
return models.Post.findPage(frame.options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Express Response Directly
|
||||
|
||||
For streaming or special responses:
|
||||
|
||||
```javascript
|
||||
query(frame) {
|
||||
// Return a function to handle Express response
|
||||
return function handler(req, res, next) {
|
||||
const stream = generateStream();
|
||||
stream.pipe(res);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Setting Custom Headers in Query
|
||||
|
||||
```javascript
|
||||
query(frame) {
|
||||
// Set headers from within query
|
||||
frame.setHeader('X-Custom-Header', 'value');
|
||||
|
||||
return models.Post.findPage(frame.options);
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Use `@tryghost/errors` for consistent error responses:
|
||||
|
||||
```javascript
|
||||
const errors = require('@tryghost/errors');
|
||||
|
||||
query(frame) {
|
||||
if (!frame.data.posts[0].title) {
|
||||
throw new errors.ValidationError({
|
||||
message: 'Title is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (notFound) {
|
||||
throw new errors.NotFoundError({
|
||||
message: 'Post not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (noAccess) {
|
||||
throw new errors.NoPermissionError({
|
||||
message: 'You do not have permission to access this resource'
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always specify `permissions`** - Never omit this field, it's a security requirement
|
||||
2. **Use `options` to whitelist params** - Only allowed params are passed through
|
||||
3. **Prefer declarative validation** - Use the validation object over custom functions
|
||||
4. **Set `cacheInvalidate` appropriately** - True for mutations, false for reads
|
||||
5. **Use `unsafeAttrs` for sensitive fields** - Requires elevated permissions to modify
|
||||
6. **Return model responses from `query`** - Let serializers handle transformation
|
||||
7. **Use `data` for READ endpoints** - When the model expects `findOne(data, options)`
|
||||
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user