JSON REST API

Core Library and Plugins

This guide covers the fundamentals of json-rest-api, starting with basic usage and progressively covering more advanced features.


1. Basics

How to create an API with createApi

The simplest way to get started is using the createApi helper function:

import { createApi, Schema } from 'json-rest-api';
import express from 'express';

const app = express();
const api = createApi({ 
  storage: 'memory',  // Use in-memory database
  http: { app }       // Attach to Express app
});

// Define a resource
api.addResource('books', new Schema({
  title: { type: 'string', required: true },
  author: { type: 'string', required: true },
  isbn: { type: 'string' },
  year: { type: 'number' }
}));

app.listen(3000);

This creates a fully functional REST API with:

How to do the same thing by hand

For more control, you can manually configure the API:

import { Api, Schema, MemoryPlugin, HTTPPlugin, ValidationPlugin } from 'json-rest-api';
import express from 'express';

const app = express();
const api = new Api();

// Add plugins manually
api.use(ValidationPlugin);     // Enable validation
api.use(MemoryPlugin);         // Use in-memory storage
api.use(HTTPPlugin, { app });  // Enable HTTP endpoints

// Define resource
api.addResource('books', new Schema({
  title: { type: 'string', required: true },
  author: { type: 'string', required: true },
  isbn: { type: 'string' },
  year: { type: 'number' }
}));

app.listen(3000);

Core Plugins

The following plugins provide basic functionality:

  1. MemoryPlugin (./plugins/core/memory.js) - In-memory SQL database using AlaSQL
  2. MySQLPlugin (./plugins/core/mysql.js) - MySQL/MariaDB storage with connection pooling
  3. HTTPPlugin (./plugins/core/http.js) - REST endpoints with Express integration
  4. ValidationPlugin (./plugins/core/validation.js) - Schema-based validation
  5. PositioningPlugin (./plugins/core/positioning.js) - Ordered records
  6. TimestampsPlugin (./plugins/core/timestamps.js) - Automatic created/updated timestamps

Note: VersioningPlugin is in core-extra, not core plugins.

What a resource file normally looks like

In a real project, you’d typically organize resources in separate files:

// resources/users.js
import { Schema } from 'json-rest-api';

export const userSchema = new Schema({
  name: { type: 'string', required: true },
  email: { type: 'string', required: true },
  password: { type: 'string', silent: true }, // Never returned in responses
  role: { type: 'string', default: 'user' },
  active: { type: 'boolean', default: true }
});

export const userHooks = {
  beforeInsert: async (context) => {
    // Hash password before storing
    context.data.password = await hashPassword(context.data.password);
  },
  
  afterGet: async (context) => {
    // Add computed fields
    context.result.displayName = `${context.result.name} (${context.result.role})`;
  }
};

// resources/index.js
import { userSchema, userHooks } from './users.js';
import { postSchema, postHooks } from './posts.js';

export function registerResources(api) {
  api.addResource('users', userSchema, { hooks: userHooks });
  api.addResource('posts', postSchema, { hooks: postHooks });
}

How to integrate with Express

Starting from a fresh Express application:

// server.js
import express from 'express';
import { createApi } from 'json-rest-api';
import { registerResources } from './resources/index.js';

// Create Express app with required middleware
const app = express();

// IMPORTANT: These middleware are required
app.use(express.json());                    // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies

// Optional but recommended middleware
app.use(helmet());                          // Security headers
app.use(compression());                     // Gzip compression
app.use(cors());                           // CORS support

// Create API
const api = createApi({ 
  storage: 'memory',
  http: { 
    app,
    prefix: '/api/v1'  // Optional: prefix all routes
  }
});

// Register your resources
registerResources(api);

// Your own routes can coexist
app.get('/', (req, res) => {
  res.send('Welcome to my API');
});

// Error handling
app.use((err, req, res, next) => {
  console.error(err);
  res.status(err.status || 500).json({
    error: err.message || 'Internal server error'
  });
});

app.listen(3000);

Structuring your API

Recommended directory structure:

project/
├── server.js           # Main entry point
├── config/
│   └── database.js     # Database configuration
├── resources/
│   ├── index.js        # Resource registration
│   ├── users.js        # User resource
│   ├── posts.js        # Post resource
│   └── comments.js     # Comment resource
├── hooks/
│   └── auth.js         # Shared authentication hooks
└── plugins/
    └── custom.js       # Custom plugins

Memory vs MySQL - Totally Interchangeable

One of the key features is that storage plugins are completely interchangeable:

// Development - use memory
const api = createApi({ 
  storage: 'memory',
  http: { app }
});

// Production - use MySQL
const api = createApi({ 
  storage: 'mysql',
  mysql: {
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME
  },
  http: { app }
});

Your resource definitions, hooks, and business logic remain exactly the same!


2. Relations

Basic One-to-Many Example

Let’s create a blog with authors and posts:

// Define authors
api.addResource('authors', new Schema({
  name: { type: 'string', required: true },
  email: { type: 'string', required: true },
  bio: { type: 'string' }
}));

// Define posts with author relationship
api.addResource('posts', new Schema({
  title: { type: 'string', required: true },
  content: { type: 'string', required: true },
  authorId: {
    type: 'id',
    required: true,
    refs: {
      resource: 'authors',    // Links to authors resource
      join: {
        eager: true,          // Auto-include author data
        fields: ['id', 'name'] // Only include these fields
      }
    }
  },
  published: { type: 'boolean', default: false }
}));

What the returned data looks like (JSON:API)

When you fetch a post, the response follows JSON:API format:

{
  "data": {
    "id": "1",
    "type": "posts",
    "attributes": {
      "title": "My First Post",
      "content": "This is the content...",
      "authorId": "42",
      "published": true
    },
    "relationships": {
      "author": {
        "data": { "type": "authors", "id": "42" }
      }
    }
  },
  "included": [
    {
      "id": "42",
      "type": "authors",
      "attributes": {
        "name": "John Doe"
      }
    }
  ]
}

Using api.resources

The library provides an intuitive way to work with resources:

// Create an author
const author = await api.resources.authors.create({
  name: 'Jane Smith',
  email: 'jane@example.com',
  bio: 'Tech writer and developer'
});

// Create a post for that author
const post = await api.resources.posts.create({
  title: 'Understanding REST APIs',
  content: 'REST APIs are...',
  authorId: author.data.id
});

// Query posts by author
const authorPosts = await api.resources.posts.query({
  filter: { authorId: author.data.id }
});

// Get a specific post (will include author data)
const fullPost = await api.resources.posts.get(post.data.id);

One-to-Many Relationships (Reverse)

Authors can have many posts. Define this as a virtual field:

api.addResource('authors', new Schema({
  name: { type: 'string', required: true },
  email: { type: 'string', required: true },
  bio: { type: 'string' },
  
  // Virtual field - not stored in database
  posts: {
    type: 'list',
    virtual: true,
    foreignResource: 'posts',
    foreignKey: 'authorId',      // Field in posts that references this author
    join: {
      eager: true,               // Auto-load posts
      include: ['categoryId']    // Include category for each post
    },
    defaultFilter: { published: true }, // Only show published posts
    defaultSort: '-createdAt'          // Newest first
  }
}));

IMPORTANT: The foreign key field (authorId in posts) must be marked as searchable: true:

api.addResource('posts', new Schema({
  // ... other fields ...
  authorId: {
    type: 'id',
    required: true,
    searchable: true,  // REQUIRED for to-many relationships
    refs: {
      resource: 'authors'
    }
  }
}));

Complex Nested Relationships

Let’s add categories and tags:

// Categories
api.addResource('categories', new Schema({
  name: { type: 'string', required: true },
  slug: { type: 'string', required: true }
}));

// Tags
api.addResource('tags', new Schema({
  name: { type: 'string', required: true },
  color: { type: 'string' }
}));

// Posts with multiple relationships
api.addResource('posts', new Schema({
  title: { type: 'string', required: true },
  content: { type: 'string', required: true },
  
  // Many-to-one: Post belongs to author
  authorId: {
    type: 'id',
    required: true,
    searchable: true,
    refs: {
      resource: 'authors',
      join: {
        eager: true,
        fields: ['id', 'name', 'email'],
        preserveId: true  // Keep authorId field in response
      }
    }
  },
  
  // Many-to-one: Post belongs to category
  categoryId: {
    type: 'id',
    refs: {
      resource: 'categories',
      join: {
        eager: true,
        fields: ['id', 'name', 'slug']
      }
    }
  },
  
  // Many-to-many: Posts have many tags (via junction table)
  tags: {
    type: 'list',
    virtual: true,
    foreignResource: 'post_tags',
    foreignKey: 'postId',
    join: {
      eager: true,
      include: ['tagId']  // Include tag details from junction
    }
  }
}));

// Junction table for many-to-many
api.addResource('post_tags', new Schema({
  postId: {
    type: 'id',
    required: true,
    searchable: true,
    refs: { resource: 'posts' }
  },
  tagId: {
    type: 'id',
    required: true,
    searchable: true,
    refs: {
      resource: 'tags',
      join: {
        eager: true,
        fields: ['id', 'name', 'color']
      }
    }
  }
}));

All Relationship Options

refs options:

Virtual list field options:

Nested Include Example

Fetch a post with author and the author’s country:

// Define countries
api.addResource('countries', new Schema({
  name: { type: 'string', required: true },
  code: { type: 'string', required: true }
}));

// Update authors to include country
api.addResource('authors', new Schema({
  name: { type: 'string', required: true },
  countryId: {
    type: 'id',
    refs: {
      resource: 'countries',
      join: { eager: true }
    }
  }
}));

// Fetch post with nested includes
const post = await api.resources.posts.get(1, {
  include: 'authorId.countryId'  // Include author's country
});

Response includes nested data:

{
  "data": {
    "id": "1",
    "type": "posts",
    "attributes": {
      "title": "My Post",
      "authorId": "10"
    },
    "relationships": {
      "author": {
        "data": { "type": "authors", "id": "10" }
      }
    }
  },
  "included": [
    {
      "id": "10",
      "type": "authors",
      "attributes": {
        "name": "Jane Smith",
        "countryId": "1"
      },
      "relationships": {
        "country": {
          "data": { "type": "countries", "id": "1" }
        }
      }
    },
    {
      "id": "1",
      "type": "countries",
      "attributes": {
        "name": "United States",
        "code": "US"
      }
    }
  ]
}

3. Querying

Defining Searchable Fields

Fields must be marked as searchable to be used in filters:

api.addResource('products', new Schema({
  name: { 
    type: 'string', 
    required: true,
    searchable: true  // Can filter by name
  },
  description: { type: 'string' },  // NOT searchable
  price: { 
    type: 'number',
    searchable: true  // Can filter by price
  },
  category: { 
    type: 'string',
    searchable: true  // Can filter by category
  },
  inStock: { 
    type: 'boolean',
    searchable: true  // Can filter by stock status
  }
}));

Basic Filtering

Filter using query parameters:

GET /products?filter[category]=electronics
GET /products?filter[inStock]=true
GET /products?filter[name]=iPhone

In code:

// Find all electronics
const electronics = await api.resources.products.query({
  filter: { category: 'electronics' }
});

// Find products in stock
const inStock = await api.resources.products.query({
  filter: { inStock: true }
});

Advanced Filtering Operators

Use operators for complex queries:

// Price greater than 100
const expensive = await api.resources.products.query({
  filter: { price: { gt: 100 } }
});

// Price between 50 and 200
const midRange = await api.resources.products.query({
  filter: { 
    price: { 
      gte: 50,
      lte: 200 
    }
  }
});

// Name contains "phone"
const phones = await api.resources.products.query({
  filter: { name: { like: '%phone%' } }
});

// Category in list
const techProducts = await api.resources.products.query({
  filter: { 
    category: { 
      in: ['electronics', 'computers'] 
    }
  }
});

Available operators:

Sorting

Sort results using the sort parameter:

// Sort by price ascending
const cheapFirst = await api.resources.products.query({
  sort: 'price'
});

// Sort by price descending (prefix with -)
const expensiveFirst = await api.resources.products.query({
  sort: '-price'
});

// Multiple sort fields
const sorted = await api.resources.products.query({
  sort: ['-inStock', 'price']  // In stock first, then by price
});

Pagination

Control result size and pagination:

// Get first 10 products
const firstPage = await api.resources.products.query({
  page: { size: 10, number: 1 }
});

// Get next 10 products
const secondPage = await api.resources.products.query({
  page: { size: 10, number: 2 }
});

// Response includes pagination metadata
console.log(firstPage.meta);
// {
//   page: { size: 10, number: 1, total: 4 },
//   total: 37
// }

Field Selection

Optimize responses by selecting specific fields:

// Only get name and price
const summary = await api.resources.products.query({
  fields: { products: ['name', 'price'] }
});

Combining Query Features

// Complex query example
const results = await api.resources.products.query({
  filter: {
    category: 'electronics',
    price: { lte: 500 },
    inStock: true
  },
  sort: '-price',
  page: { size: 20, number: 1 },
  fields: { products: ['name', 'price', 'category'] },
  include: 'manufacturerId'  // Include related manufacturer
});

Virtual Searchable Fields

You can define computed searchable fields:

api.addResource('users', new Schema({
  firstName: { type: 'string', required: true },
  lastName: { type: 'string', required: true }
}), {
  searchableFields: {
    // Virtual field that searches across multiple real fields
    name: {
      type: 'string',
      resolve: (value) => ({
        OR: [
          { firstName: { like: `%${value}%` } },
          { lastName: { like: `%${value}%` } }
        ]
      })
    },
    // Map frontend field to database field
    fullName: 'CONCAT(firstName, " ", lastName)'
  }
});

// Now you can search by name
const johns = await api.resources.users.query({
  filter: { name: 'john' }  // Searches firstName OR lastName
});

4. Validation

How Validation Works with Schema

Every field in a schema can have validation rules:

const userSchema = new Schema({
  username: {
    type: 'string',
    required: true,
    min: 3,        // Minimum length
    max: 20,       // Maximum length
    pattern: /^[a-zA-Z0-9_]+$/,  // Regex pattern
    lowercase: true,              // Convert to lowercase
    trim: true                    // Remove whitespace
  },
  
  email: {
    type: 'string',
    required: true,
    format: 'email',  // Built-in email validation
    validator: async (value) => {
      // Custom async validation
      const exists = await checkEmailExists(value);
      if (exists) {
        throw new Error('Email already registered');
      }
    }
  },
  
  age: {
    type: 'number',
    min: 0,
    max: 150,
    required: function(data) {
      // Conditional requirement
      return data.role === 'student';
    }
  },
  
  role: {
    type: 'string',
    enum: ['admin', 'user', 'guest'],  // Must be one of these
    default: 'user'
  },
  
  tags: {
    type: 'array',
    maxItems: 10,     // Maximum array length
    items: {          // Validate each item
      type: 'string',
      min: 2
    }
  },
  
  metadata: {
    type: 'object',
    maxKeys: 20,      // Maximum object properties
    required: false,
    default: {}
  }
});

Understanding the Schema Object

The Schema class provides:

const schema = new Schema(structure, options);

// Options:
{
  strictMode: true,    // Reject unknown fields (default: true)
  maxItems: 1000,      // Max array size (default: 1000)
  maxKeys: 100,        // Max object keys (default: 100)
  maxDepth: 10,        // Max nesting depth (default: 10)
  emptyAsNull: false,  // Treat empty strings as null
  canBeNull: false     // Allow null values
}

// Methods:
await schema.validate(data, options);  // Validate data
schema.use(plugin);                    // Add schema plugin
schema.registerType(name, handler);    // Custom types
schema.registerParam(name, handler);   // Custom validators

Built-in Types

Built-in Validators

General:

Strings:

Numbers:

Arrays:

Objects:

Validation Process

// Validation happens automatically before insert/update
try {
  await api.resources.users.create({
    username: 'jd',  // Too short!
    email: 'invalid-email',
    age: -5
  });
} catch (error) {
  console.log(error.errors);
  // [
  //   { field: 'username', message: 'Minimum length is 3' },
  //   { field: 'email', message: 'Invalid email format' },
  //   { field: 'age', message: 'Minimum value is 0' }
  // ]
}

Custom Validators

// Synchronous validator
const isValidUsername = {
  validator: (value) => {
    if (reservedUsernames.includes(value)) {
      return 'Username is reserved';
    }
    return true;  // Valid
  }
};

// Asynchronous validator
const uniqueEmail = {
  validator: async (value, { context }) => {
    const existing = await context.api.resources.users.query({
      filter: { email: value }
    });
    
    if (existing.data.length > 0) {
      // Check if it's an update to the same record
      if (context.method === 'update' && 
          existing.data[0].id === context.id) {
        return true;  // Same record, allow
      }
      return 'Email already exists';
    }
    return true;
  }
};

// Use in schema
new Schema({
  username: { type: 'string', ...isValidUsername },
  email: { type: 'string', ...uniqueEmail }
});

Conditional Validation

const orderSchema = new Schema({
  status: {
    type: 'string',
    enum: ['pending', 'processing', 'shipped', 'delivered']
  },
  
  shippingAddress: {
    type: 'string',
    required: function(data) {
      // Required only when status is shipped/delivered
      return ['shipped', 'delivered'].includes(data.status);
    }
  },
  
  trackingNumber: {
    type: 'string',
    validator: function(value, { data }) {
      if (data.status === 'shipped' && !value) {
        return 'Tracking number required for shipped orders';
      }
      return true;
    }
  }
});

Validation Modes

// Strict validation for insert (all required fields must be present)
await api.resources.users.create({
  username: 'john',
  email: 'john@example.com'
  // Missing required fields will cause error
});

// Partial validation for update (only validate provided fields)
await api.resources.users.update(userId, {
  email: 'newemail@example.com'
  // Only email is validated, other fields unchanged
});

// Full record validation for update
await api.resources.users.update(userId, {
  email: 'newemail@example.com'
}, {
  validateFullRecord: true  // Fetch and validate entire record
});

5. Positioning

How Positioning Works

The positioning plugin maintains ordered records using a position field:

api.addResource('tasks', new Schema({
  title: { type: 'string', required: true },
  position: { type: 'number' }  // Managed by positioning plugin
}), {
  positioning: {
    field: 'position',        // Field name (default: 'position')
    startAt: 1000,           // Starting position (default: 1000)
    increment: 1000          // Gap between positions (default: 1000)
  }
});

Why It’s Invaluable

  1. User-defined ordering: Let users arrange items (drag-and-drop)
  2. Stable sorting: Maintains order even with same timestamps
  3. Efficient reordering: Move items without updating everything
  4. Gap-based positioning: Minimizes position conflicts

Full Tutorial

Basic Usage

// Create tasks - positions are auto-assigned
const task1 = await api.resources.tasks.create({
  title: 'First task'
});
console.log(task1.data.attributes.position); // 1000

const task2 = await api.resources.tasks.create({
  title: 'Second task'
});
console.log(task2.data.attributes.position); // 2000

const task3 = await api.resources.tasks.create({
  title: 'Third task'
});
console.log(task3.data.attributes.position); // 3000

Inserting Between Items

// Insert before task2 using beforeId
const task1_5 = await api.resources.tasks.create({
  title: 'Task 1.5',
  beforeId: task2.data.id  // Virtual field
});
console.log(task1_5.data.attributes.position); // 1500

// Tasks are now ordered: task1 (1000), task1_5 (1500), task2 (2000), task3 (3000)

Moving Items

// Move task3 to the beginning
await api.resources.tasks.update(task3.data.id, {
  beforeId: task1.data.id
});

// Move task1 to the end
await api.resources.tasks.update(task1.data.id, {
  beforeId: null  // null means end of list
});

Querying Ordered Items

// Get tasks in position order
const orderedTasks = await api.resources.tasks.query({
  sort: 'position'  // Sort by position field
});

Scoped Positioning

Position items within groups:

api.addResource('board_cards', new Schema({
  title: { type: 'string', required: true },
  boardId: { type: 'id', required: true, searchable: true },
  columnId: { type: 'id', required: true, searchable: true },
  position: { type: 'number' }
}), {
  positioning: {
    field: 'position',
    scope: ['boardId', 'columnId']  // Separate positions per board/column
  }
});

// Cards in different columns have independent positions
const card1 = await api.resources.board_cards.create({
  title: 'Card 1',
  boardId: 1,
  columnId: 1
}); // position: 1000 in column 1

const card2 = await api.resources.board_cards.create({
  title: 'Card 2',
  boardId: 1,
  columnId: 2
}); // position: 1000 in column 2 (different scope)

Handling Position Conflicts

The plugin automatically handles conflicts:

// If positions get too close, the plugin rebases them
// This happens automatically when gaps get too small

// Manual rebase if needed
await api.rebasePositions('tasks', {
  scope: { projectId: 5 },  // Optional scope
  startAt: 1000,
  increment: 1000
});

Advanced Features

// Custom position calculation
api.addResource('priorities', new Schema({
  name: { type: 'string', required: true },
  priority: { type: 'number' }
}), {
  positioning: {
    field: 'priority',
    startAt: 100,
    increment: 10,
    
    // Custom position calculator
    calculatePosition: async (context) => {
      const { beforeId, data } = context;
      
      // Custom logic for critical items
      if (data.critical) {
        return 1;  // Always first
      }
      
      // Default behavior for others
      return null;  // Let plugin calculate
    }
  }
});

// Disable positioning for specific operations
await api.resources.tasks.update(taskId, data, {
  positioning: { enabled: false }  // Skip position calculation
});

Best Practices

  1. Always use the position field for ordering: Don’t rely on creation order
  2. Use beforeId for user-driven reordering: It’s more intuitive than setting positions
  3. Scope positions when needed: Keep independent orderings for different contexts
  4. Let the plugin manage positions: Don’t set position values manually
  5. Use appropriate increments: Larger increments (1000) handle more reorderings

6. Hooks

Resource-Wide Hooks

Define hooks that run for specific resources:

api.addResource('posts', postSchema, {
  hooks: {
    // Before hooks - modify data before operations
    beforeInsert: async (context) => {
      // Auto-generate slug from title
      context.data.slug = context.data.title
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-');
        
      // Set author from authenticated user
      context.data.authorId = context.options.user?.id;
    },
    
    beforeUpdate: async (context) => {
      // Track who modified the post
      context.data.lastModifiedBy = context.options.user?.id;
      
      // Prevent changing author
      if (context.data.authorId) {
        delete context.data.authorId;
      }
    },
    
    beforeDelete: async (context) => {
      // Check permissions
      const post = await context.api.resources.posts.get(context.id);
      if (post.data.attributes.authorId !== context.options.user?.id) {
        throw new ForbiddenError('Can only delete your own posts');
      }
    },
    
    // After hooks - process results
    afterGet: async (context) => {
      // Add computed fields
      const post = context.result;
      
      // Calculate reading time
      const words = post.content.split(' ').length;
      post.readingTime = Math.ceil(words / 200); // 200 words per minute
      
      // Add author's full name if included
      if (post.author) {
        post.authorName = post.author.name;
      }
    },
    
    afterQuery: async (context) => {
      // Process all results
      for (const post of context.results) {
        // Add summary for list views
        post.summary = post.content.substring(0, 200) + '...';
      }
      
      // Add metadata
      context.meta.generatedAt = new Date().toISOString();
    },
    
    afterInsert: async (context) => {
      // Send notification
      await notifyFollowers(context.result.authorId, {
        type: 'new_post',
        postId: context.result.id,
        title: context.result.title
      });
      
      // Update author's post count
      await context.api.query(
        `UPDATE authors SET postCount = postCount + 1 WHERE id = ?`,
        [context.result.authorId]
      );
    },
    
    afterUpdate: async (context) => {
      // Clear cache
      await cache.delete(`post:${context.id}`);
      
      // Log activity
      await context.api.resources.activity_logs.create({
        resource: 'posts',
        action: 'update',
        resourceId: context.id,
        userId: context.options.user?.id,
        changes: context.data
      });
    },
    
    afterDelete: async (context) => {
      // Cascade delete comments
      await context.api.query(
        `DELETE FROM comments WHERE postId = ?`,
        [context.id]
      );
      
      // Update author's post count
      await context.api.query(
        `UPDATE authors SET postCount = postCount - 1 WHERE id = ?`,
        [context.originalRecord.authorId]
      );
    }
  }
});

API-Wide Hooks

Define hooks that run for all resources:

// Authentication hook - runs for all resources
api.hook('beforeGet', async (context) => {
  // Public resources don't need auth
  const publicResources = ['categories', 'tags'];
  if (publicResources.includes(context.options.type)) {
    return;
  }
  
  // Require authentication for other resources
  if (!context.options.user) {
    throw new UnauthorizedError('Authentication required');
  }
});

// Audit logging - runs for all modifications
api.hook('afterInsert', async (context) => {
  await logActivity({
    action: 'create',
    resource: context.options.type,
    resourceId: context.result.id,
    userId: context.options.user?.id,
    data: context.data
  });
}, 100); // Priority 100 - runs after resource hooks

// Rate limiting
const requestCounts = new Map();
api.hook('beforeQuery', async (context) => {
  const userId = context.options.user?.id || context.options.ip;
  const key = `${userId}:${Date.now() / 60000 | 0}`; // Per minute
  
  const count = (requestCounts.get(key) || 0) + 1;
  requestCounts.set(key, count);
  
  if (count > 100) { // 100 requests per minute
    throw new RateLimitError('Too many requests');
  }
});

// Transform all responses
api.hook('transformResult', async (context) => {
  // Add metadata to all responses
  if (context.result) {
    context.meta = context.meta || {};
    context.meta.version = '1.0';
    context.meta.timestamp = new Date().toISOString();
  }
});

Complete Hook Lifecycle

The complete order of hook execution:

For GET operations:

  1. beforeGet - Modify request, check permissions
  2. Storage plugin fetches data
  3. afterGet - Process result, add computed fields
  4. transformResult - Final response transformation

For QUERY operations:

  1. beforeQuery - Modify filters, check permissions
  2. Storage plugin queries data
  3. afterQuery - Process results array
  4. afterGet - Runs for each result (optional)
  5. transformResult - Run for each result

For INSERT operations:

  1. beforeValidate - Pre-process data
  2. Validation runs
  3. afterValidate - Post-validation processing
  4. beforeInsert - Final data modifications
  5. Storage plugin inserts data
  6. afterInsert - Post-insert actions
  7. transformResult - Format response

For UPDATE operations:

  1. beforeValidate - Pre-process data
  2. Validation runs
  3. afterValidate - Post-validation processing
  4. beforeUpdate - Final data modifications
  5. Storage plugin updates data
  6. afterUpdate - Post-update actions
  7. transformResult - Format response

For DELETE operations:

  1. beforeDelete - Check permissions, prepare
  2. Storage plugin deletes data
  3. afterDelete - Cleanup, cascade deletes

Hook Context Object

Every hook receives a context object:

{
  api: Api,              // API instance
  method: string,        // 'get', 'query', 'insert', 'update', 'delete'
  options: {
    type: string,        // Resource type
    user: object,        // Authenticated user
    artificialDelay: number,
    // ... other options
  },
  
  // Method-specific properties:
  id: any,              // For get, update, delete
  data: object,         // For insert, update
  params: object,       // For query (filters, sort, etc.)
  result: object,       // For afterX hooks
  results: array,       // For afterQuery
  meta: object,         // Response metadata
  errors: array,        // Validation errors
  
  // Hook modifications:
  skipDefaults: boolean, // Skip default behaviors
  // ... custom properties
}

Hook Priorities

Control hook execution order with priorities:

// Lower numbers run first (default is 50)

// Run authentication first
api.hook('beforeGet', authHook, 10);

// Then check permissions
api.hook('beforeGet', permissionHook, 20);

// Then apply business logic
api.hook('beforeGet', businessLogicHook, 30);

// Resource-specific hooks run at priority 50

// Logging runs last
api.hook('afterGet', loggingHook, 90);

Stopping Hook Execution

Return false to stop the hook chain:

api.hook('beforeInsert', async (context) => {
  if (context.options.type === 'posts') {
    const dailyLimit = await checkDailyPostLimit(context.options.user);
    if (dailyLimit.exceeded) {
      context.errors.push({
        field: 'general',
        message: 'Daily post limit exceeded'
      });
      return false; // Stop execution
    }
  }
});

Modifying Hook Behavior

// Skip remaining hooks
api.hook('beforeUpdate', async (context) => {
  if (context.data.skipValidation) {
    context.skipDefaults = true; // Skip default behaviors
    context.skipHooks = ['validate']; // Skip specific hooks
  }
});

// Modify query in flight
api.hook('beforeQuery', async (context) => {
  // Add default filter
  context.params.filter = context.params.filter || {};
  context.params.filter.deleted = false;
  
  // Force sorting
  if (!context.params.sort) {
    context.params.sort = '-createdAt';
  }
});

// Transform errors
api.hook('beforeSend', async (context) => {
  if (context.errors && context.errors.length > 0) {
    // Transform errors for frontend
    context.errors = context.errors.map(err => ({
      code: err.code || 'VALIDATION_ERROR',
      title: 'Validation Failed',
      detail: err.message,
      source: { pointer: `/data/attributes/${err.field}` }
    }));
  }
});

7. API Usage

Using the Module Programmatically

The API can be used programmatically without HTTP:

import { createApi, Schema } from 'json-rest-api';

// Create API without HTTP
const api = createApi({ 
  storage: 'memory'  // No http option
});

// Define resources as usual
api.addResource('users', new Schema({
  name: { type: 'string', required: true },
  email: { type: 'string', required: true }
}));

// Use the API programmatically
async function main() {
  // Create a user
  const user = await api.resources.users.create({
    name: 'Alice',
    email: 'alice@example.com'
  });
  console.log('Created user:', user.data);
  
  // Query users
  const users = await api.resources.users.query({
    filter: { name: { like: '%Ali%' } }
  });
  console.log('Found users:', users.data);
  
  // Update user
  const updated = await api.resources.users.update(user.data.id, {
    name: 'Alice Smith'
  });
  console.log('Updated user:', updated.data);
  
  // Get with relationships
  const fullUser = await api.resources.users.get(user.data.id, {
    include: 'posts,comments'
  });
  console.log('User with relations:', fullUser);
}

main().catch(console.error);

Direct API Methods

// All methods return JSON:API formatted responses

// CREATE
const result = await api.insert(data, options);
// or
const result = await api.resources[type].create(data, options);

// READ
const result = await api.get(id, options);
const results = await api.query(params, options);
// or
const result = await api.resources[type].get(id, options);
const results = await api.resources[type].query(params, options);

// UPDATE
const result = await api.update(id, data, options);
// or
const result = await api.resources[type].update(id, data, options);

// DELETE
await api.delete(id, options);
// or
await api.resources[type].delete(id, options);

Options Parameter

const options = {
  type: 'users',           // Resource type (required for direct API methods)
  user: {                  // Authenticated user
    id: 123,
    roles: ['admin'],
    permissions: ['read', 'write']
  },
  include: 'posts,comments', // Relationships to include
  fields: {                  // Sparse fieldsets
    users: ['name', 'email'],
    posts: ['title', 'content']
  },
  artificialDelay: 100,      // Testing: add delay
  skipHooks: ['afterGet'],   // Skip specific hooks
  transaction: trx,          // Database transaction
  // ... custom options for plugins
};

Batch Operations

// Batch create
const users = await api.resources.users.batch.create([
  { name: 'User 1', email: 'user1@example.com' },
  { name: 'User 2', email: 'user2@example.com' },
  { name: 'User 3', email: 'user3@example.com' }
]);

// Batch update
const updates = await api.resources.users.batch.update([
  { id: 1, data: { name: 'Updated User 1' } },
  { id: 2, data: { name: 'Updated User 2' } }
]);

// Batch delete
await api.resources.users.batch.delete([1, 2, 3]);

Working with Transactions

// MySQL transactions
const connection = await api.getConnection();
const trx = await connection.beginTransaction();

try {
  // All operations use the same transaction
  const author = await api.resources.authors.create({
    name: 'New Author'
  }, { transaction: trx });
  
  const post = await api.resources.posts.create({
    title: 'New Post',
    authorId: author.data.id
  }, { transaction: trx });
  
  await trx.commit();
} catch (error) {
  await trx.rollback();
  throw error;
} finally {
  connection.release();
}

Custom Queries

// Direct SQL queries (when needed)
const results = await api.query(
  'SELECT * FROM users WHERE created_at > ? ORDER BY name',
  [new Date('2024-01-01')]
);

// Using QueryBuilder
import { QueryBuilder } from 'json-rest-api';

const query = new QueryBuilder('users', api)
  .select('users.*', 'COUNT(posts.id) as post_count')
  .leftJoin('posts', 'posts.authorId = users.id')
  .where('users.active = ?', true)
  .groupBy('users.id')
  .having('COUNT(posts.id) > ?', 5)
  .orderBy('post_count', 'DESC')
  .limit(10);

const sql = query.build();
const results = await api.query(sql.sql, sql.args);

Error Handling

import { 
  ValidationError, 
  NotFoundError, 
  UnauthorizedError,
  ForbiddenError,
  ConflictError 
} from 'json-rest-api';

try {
  await api.resources.users.create({
    name: 'a'  // Too short!
  });
} catch (error) {
  if (error instanceof ValidationError) {
    console.log('Validation errors:', error.errors);
    // [{ field: 'name', message: 'Minimum length is 2' }]
  } else if (error instanceof NotFoundError) {
    console.log('Resource not found');
  } else if (error instanceof UnauthorizedError) {
    console.log('Authentication required');
  } else {
    // Handle other errors
    console.error('Unexpected error:', error);
  }
}

Events and Notifications

// Emit custom events from hooks
api.hook('afterInsert', async (context) => {
  if (context.options.type === 'orders') {
    api.emit('order:created', {
      order: context.result,
      user: context.options.user
    });
  }
});

// Listen for events
api.on('order:created', async ({ order, user }) => {
  await sendOrderConfirmation(order, user);
  await updateInventory(order);
  await notifyWarehouse(order);
});

Testing

import { createApi } from 'json-rest-api';
import { test } from 'your-test-framework';

test('user creation', async () => {
  // Create test API with memory storage
  const api = createApi({ storage: 'memory' });
  
  api.addResource('users', userSchema);
  
  // Test user creation
  const user = await api.resources.users.create({
    name: 'Test User',
    email: 'test@example.com'
  });
  
  expect(user.data.attributes.name).toBe('Test User');
  expect(user.data.id).toBeDefined();
  
  // Test query
  const users = await api.resources.users.query({
    filter: { email: 'test@example.com' }
  });
  
  expect(users.data).toHaveLength(1);
  expect(users.data[0].id).toBe(user.data.id);
});

Performance Monitoring

// Add performance monitoring
api.hook('beforeQuery', async (context) => {
  context.startTime = Date.now();
});

api.hook('afterQuery', async (context) => {
  const duration = Date.now() - context.startTime;
  
  if (duration > 1000) { // Log slow queries
    console.warn('Slow query detected:', {
      type: context.options.type,
      duration: `${duration}ms`,
      filters: context.params.filter,
      resultCount: context.results.length
    });
  }
  
  // Add to response metadata
  context.meta.queryTime = `${duration}ms`;
});

Advanced Patterns

// 1. Multi-tenancy
api.hook('beforeQuery', async (context) => {
  // Always filter by tenant
  context.params.filter = context.params.filter || {};
  context.params.filter.tenantId = context.options.user.tenantId;
});

// 2. Soft deletes
api.hook('beforeDelete', async (context) => {
  // Convert delete to update
  context.method = 'update';
  context.data = { 
    deletedAt: new Date().toISOString(),
    deletedBy: context.options.user.id
  };
  return false; // Don't actually delete
});

// 3. Caching
const cache = new Map();

api.hook('afterGet', async (context) => {
  // Cache the result
  const key = `${context.options.type}:${context.id}`;
  cache.set(key, {
    data: context.result,
    timestamp: Date.now()
  });
});

api.hook('beforeGet', async (context) => {
  const key = `${context.options.type}:${context.id}`;
  const cached = cache.get(key);
  
  if (cached && Date.now() - cached.timestamp < 60000) { // 1 minute
    context.result = cached.data;
    context.fromCache = true;
    return false; // Skip database query
  }
});

Summary

This guide covered the core functionality of json-rest-api:

  1. Basics: Creating APIs, using plugins, integrating with Express
  2. Relations: Defining relationships, working with related data
  3. Querying: Filtering, sorting, pagination, field selection
  4. Validation: Schema validation, custom validators
  5. Positioning: Maintaining ordered records
  6. Hooks: Lifecycle hooks for customization
  7. API Usage: Programmatic usage, error handling, advanced patterns

The plugin architecture allows you to start simple and add features as needed. Storage plugins are interchangeable, making it easy to develop with in-memory storage and deploy with MySQL.

For more advanced features, see the Core Extra Plugins documentation.