# 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)`