Files
DuckQ1u 93d1b7c3d3
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
first commit
2026-04-22 19:51:20 +07:00

14 KiB

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

{
  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

{
  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

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.

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.

options: ['include', 'filter', 'page', 'limit', 'order']

Can also be a function:

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).

data: ['id', 'slug', 'email']

validation (Object | Function)

Configure input validation. The framework validates against global validators automatically.

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:

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.

// 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.

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.

// Fixed status code
statusCode: 201,

// Dynamic based on result
statusCode: (result) => {
    return result.posts.length ? 200 : 204;
}

response (Object)

Configure response format.

response: {
    format: 'plain'  // Send as plain text instead of JSON
}

cache (Object)

Enable endpoint-level caching.

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.

generateCacheKeyData(frame) {
    // Default uses frame.options
    return {
        ...frame.options,
        customKey: 'value'
    };
}

Complete Controller Examples

Browse Endpoint (List)

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)

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)

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)

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)

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

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

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:

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:

// 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:

// 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:

// 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

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:

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

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:

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)