634 lines
14 KiB
Markdown
634 lines
14 KiB
Markdown
# 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)`
|