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