JSON REST API

Contributing to JSON REST API

Welcome, developer! This onboarding guide will help you understand the codebase architecture and get you ready to contribute. Whether you’re fixing bugs, adding features, or improving documentation, this guide will give you the knowledge you need.

Note: For the most up-to-date usage examples, check out the examples/ directory and the test files in tests/. They demonstrate current best practices and all available features.

Table of Contents

  1. What is this codebase?
  2. Architecture Overview
  3. The Main API Class
  4. Core Concepts
  5. Plugin System
  6. Storage Plugins
  7. Feature Plugins
  8. How Everything Fits Together

What is this codebase?

This is a plugin-based JSON REST API library. Think of it like building blocks:

Why is it built this way?

Most REST APIs do the same things over and over:

Instead of writing this code repeatedly, this library does it for you!

Architecture Overview

┌─────────────────────┐
│     Your App        │
├─────────────────────┤
│    API Instance     │  ← Main orchestrator
├─────────────────────┤
│     Resources       │  ← Your data types (users, posts, etc.)
├─────────────────────┤
│      Plugins        │  ← Add features
├─────────────────────┤
│   Storage Layer     │  ← Where data lives (Memory, MySQL, etc.)
└─────────────────────┘

The Main API Class

Let’s dive into the main file: lib/api.js

Creating an API Instance

const api = new Api(options);

When you create a new API, here’s what happens inside:

constructor(options = {}) {
  // Merge user options with defaults
  this.options = {
    idProperty: 'id',          // What field is the ID?
    artificialDelay: 0,        // Slow down for testing?
    apiRoot: '',              // URL prefix
    schemaVersion: null,      // Version support
    debug: false,             // Show debug logs?
    debugSQL: false,          // Show SQL queries?
    jsonApiCompliant: true,   // Return JSON:API format responses
    maxIncludeDepth: 3,       // Prevent infinite nested includes
    ...options                // Your custom options
  };

Why these defaults?

Internal Properties

this._resources = {};              // Stores all your resources
this._resourceSchemas = {};        // Stores schemas for validation
this._schemaMap = new Map();       // Fast schema lookup
this._plugins = [];                // List of installed plugins
this._hooks = {};                  // Event system
this._implementations = {};        // Plugin implementations
this._proxies = {};                // Caches for resource proxies

Why so many storage objects?

The Plugin System

use(plugin, options = {}) {
  if (!plugin || typeof plugin.install !== 'function') {
    throw new Error('Plugin must have an install method');
  }
  
  // Track that we installed this plugin
  this._plugins.push({ plugin, options });
  
  // Let the plugin set itself up
  plugin.install(this, options);
  
  return this; // For chaining: api.use(Plugin1).use(Plugin2)
}

Example of using a plugin:

api.use(MySQLPlugin, { 
  host: 'localhost',
  user: 'root',
  password: 'secret'
});

Why return this? It allows chaining: api.use(Plugin1).use(Plugin2).use(Plugin3)

Adding Resources

This is where you define your data types:

addResource(type, schema, options = {}) {
  // Validate inputs
  if (!type || typeof type !== 'string') {
    throw new ValidationError('Resource type must be a non-empty string');
  }
  
  if (!schema || !(schema instanceof Schema)) {
    throw new ValidationError('Schema must be an instance of Schema class');
  }

Example:

// Define a user schema
const userSchema = new Schema({
  id: { type: 'id' },
  name: { type: 'string', required: true },
  email: { type: 'string', required: true },
  age: { type: 'number', min: 0, max: 150 }
});

// Simple add
api.addResource('users', userSchema);

// With options
api.addResource('posts', postSchema, {
  // Map virtual search fields to real fields
  searchableFields: {
    'author': 'authorId.name',     // Filter by author name via join
    'category': 'categoryId.name', // Filter by category name
    'search': '*'                  // Virtual field for custom search
  },
  // Resource-specific hooks
  hooks: {
    afterInsert: async (context) => {
      // Send notification for new post
      await notifySubscribers(context.result);
    }
  }
});

What happens next:

  1. The schema is stored for validation
  2. Default config is created (storage, timestamps, etc.)
  3. A resource proxy is created for easy access

Resource Proxy - The Magic

When you add a resource, you can access it like this:

api.resources.users.create({ name: 'John', email: 'john@example.com' })
api.resources.users.get(123)
api.resources.users.update(123, { name: 'John Doe' })
api.resources.users.delete(123)
api.resources.users.query({ filter: { age: { $gte: 18 } } })

// Batch operations
api.resources.users.batch([
  { name: 'User 1', email: 'user1@example.com' },
  { name: 'User 2', email: 'user2@example.com' }
])

// Include related data
api.resources.users.get(123, { include: 'profile,posts' })

// Version-specific access
api.resources.users.version('2.0').get(123)

How does this work?

createResourceProxy(type) {
  const self = this;
  
  return new Proxy({}, {
    get(target, method) {
      // Map method names to implementations
      const methodMap = {
        'get': (...args) => self.get({ type }, ...args),
        'find': (...args) => self.get({ type }, ...args),
        'create': (data, options) => self.insert({ type, data }, options),
        'post': (data, options) => self.insert({ type, data }, options),
        'update': (id, data, options) => self.update({ type, id, data }, options),
        'delete': (id, options) => self.delete({ type, id }, options),
        'query': (params = {}, options = {}) => self.query({ type, ...params }, options),
        'list': (params = {}, options = {}) => self.query({ type, ...params }, options)
      };

Why use a Proxy?

Virtual Search Fields (The “*” Feature)

This is a powerful feature for implementing custom search logic:

api.addResource('posts', postSchema, {
  searchableFields: {
    title: 'title',              // Maps to real field
    authorName: 'author.name',   // Maps to joined field
    search: '*',                 // Virtual field - no direct mapping
    q: '*'                       // Another virtual field
  }
});

What does “*” mean?

How it works:

  1. User sends query:
    GET /api/posts?filter[search]=javascript
    
  2. Validation passes because ‘search’ is in searchableFields

  3. SQL generation skips the virtual field:
    // In sql-generic.js
    if (actualPath === '*') {
      // Skip - will be handled by hooks
      continue;
    }
    
  4. Your hook handles it:
    api.hook('modifyQuery', async (context) => {
      if (context.params.filter?.search && context.options.type === 'posts') {
        const searchTerm = context.params.filter.search;
           
        // Remove from filter to prevent SQL errors
        delete context.params.filter.search;
           
        // Add custom SQL for multi-field search
        context.query.where(
          '(posts.title LIKE ? OR posts.content LIKE ? OR posts.tags LIKE ?)',
          `%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`
        );
      }
    });
    

Real-world example - Advanced search syntax:

// Support complex search syntax
api.hook('modifyQuery', async (context) => {
  if (context.params.filter?.search) {
    const search = context.params.filter.search;
    delete context.params.filter.search;
    
    // Parse special syntax
    if (search.startsWith('author:')) {
      const authorName = search.substring(7);
      context.query.join('users', 'posts.authorId', 'users.id');
      context.query.where('users.name LIKE ?', `%${authorName}%`);
      
    } else if (search.startsWith('tag:')) {
      const tag = search.substring(4);
      context.query.where('posts.tags LIKE ?', `%${tag}%`);
      
    } else if (search.includes(' OR ')) {
      // Handle OR searches
      const terms = search.split(' OR ');
      const conditions = terms.map(() => 'posts.title LIKE ? OR posts.content LIKE ?');
      const params = terms.flatMap(term => [`%${term}%`, `%${term}%`]);
      context.query.where(`(${conditions.join(' OR ')})`, ...params);
      
    } else {
      // Default multi-field search
      context.query.where(
        '(posts.title LIKE ? OR posts.content LIKE ?)',
        `%${search}%`, `%${search}%`
      );
    }
  }
});

// Now users can search like:
// GET /api/posts?filter[search]=javascript
// GET /api/posts?filter[search]=author:john
// GET /api/posts?filter[search]=tag:tutorial
// GET /api/posts?filter[search]=react OR vue

Why use virtual fields?

  1. Flexibility - Implement any search logic
  2. Clean API - Users don’t need to know your schema
  3. Performance - Can optimize queries based on search type
  4. Advanced features - Full-text search, fuzzy matching, etc.

The Hook System

Hooks let plugins (and your code) react to events:

hook(eventName, handler, priority = 10) {
  if (!this._hooks[eventName]) {
    this._hooks[eventName] = [];
  }
  
  this._hooks[eventName].push({ handler, priority });
  
  // Sort by priority (lower numbers run first)
  this._hooks[eventName].sort((a, b) => a.priority - b.priority);
}

Example hooks:

// Run before any insert
api.hook('beforeInsert', async (context) => {
  // Add timestamp
  context.data.createdAt = new Date();
});

// Run after successful insert
api.hook('afterInsert', async (context) => {
  // Send email
  await sendWelcomeEmail(context.result.email);
});

Available hooks:

Running Hooks

async runHooks(eventName, context, options = {}) {
  const hooks = this._hooks[eventName] || [];
  
  for (const { handler } of hooks) {
    try {
      const result = await handler.call(this, context, options);
      
      // If a hook returns false, stop the chain
      if (result === false) {
        return false;
      }
    } catch (error) {
      // Wrap errors with context
      throw new InternalError(`Hook error in ${eventName}: ${error.message}`, {
        cause: error,
        event: eventName,
        context
      });
    }
  }
  
  return true;
}

Why can hooks return false? To stop an operation. For example:

api.hook('beforeDelete', async (context) => {
  if (context.id === 1) {
    // Don't allow deleting the admin user
    return false;
  }
});

CRUD Operations

Let’s look at each operation in detail:

INSERT (Create)

async insert(params, options = {}) {
  // 1. Validate parameters
  const validation = paramsValidation.insert.validate(params);
  if (validation.error) {
    throw new ValidationError('Invalid insert parameters', {
      validationErrors: validation.error.details
    });
  }

Input example:

api.insert({
  type: 'users',
  data: {
    name: 'John Doe',
    email: 'john@example.com',
    age: 25
  }
});

What happens step by step:

  1. Parameter validation - Checks that type and data are provided
  2. Get resource config - Looks up schema and settings
  3. Create context object:
    const context = {
      type: params.type,
      data: { ...params.data },  // Copy to avoid mutations
      options,
      api: this,
      config: this._resources[params.type]
    };
    
  4. Run beforeInsert hooks - Plugins can modify data
  5. Schema validation - Check required fields, types, etc.
  6. Execute storage insert - Actually save to database
  7. Run afterInsert hooks - Send emails, update cache, etc.
  8. Format response - Convert to JSON:API format

Output example (JSON:API format):

{
  data: {
    type: 'users',
    id: '123',
    attributes: {
      name: 'John Doe',
      email: 'john@example.com',
      age: 25,
      createdAt: '2024-01-20T10:30:00Z'
    },
    relationships: {
      profile: {
        data: { type: 'profiles', id: '456' }
      }
    }
  },
  included: [
    {
      type: 'profiles',
      id: '456',
      attributes: {
        bio: 'Software developer',
        avatar: 'https://example.com/avatar.jpg'
      }
    }
  ]
}

JSON:API Compliance:

GET (Read One)

async get(params, options = {}) {
  // Special handling for direct ID
  if (typeof params === 'object' && params.id && !params.type) {
    throw new ValidationError('Direct get() calls require type parameter');
  }

Input examples:

// Using resource proxy (recommended)
api.resources.users.get(123)

// Direct call
api.get({ type: 'users', id: 123 })

// With options
api.resources.users.get(123, { 
  fields: ['id', 'name'],  // Only get these fields
  allowNotFound: true      // Return null instead of error
})

Why allowNotFound option? Sometimes you want to check if something exists without throwing an error:

const user = await api.resources.users.get(123, { allowNotFound: true });
if (!user) {
  // User doesn't exist, create it
}

QUERY (List/Search)

This is the most complex operation:

async query(params, options = {}) {
  // Set defaults
  const queryParams = {
    filter: params.filter || {},
    fields: params.fields || null,
    sort: params.sort || null,
    page: params.page || { limit: 50, offset: 0 },
    include: params.include || '',
    ...params
  };

Input example:

api.resources.users.query({
  filter: {
    age: { $gte: 18 },           // Age >= 18
    email: { $like: '%@gmail.com' } // Gmail users
  },
  sort: ['-createdAt', 'name'],   // Newest first, then by name
  page: { limit: 10, offset: 20 }, // Page 3 (skip 20, take 10)
  fields: ['id', 'name', 'email'], // Only these fields
  include: 'profile,posts'         // Include related data
})

Filter operators:

Sort syntax:

UPDATE

async update(params, options = {}) {
  // Handle both syntaxes
  if (typeof params === 'object' && params.id && params.data && !params.type) {
    throw new ValidationError('Direct update() calls require type parameter');
  }

Input examples:

// Full update (replaces all fields)
api.resources.users.update(123, {
  name: 'Jane Doe',
  email: 'jane@example.com',
  age: 26
})

// Partial update (only specified fields)
api.resources.users.update(123, {
  age: 27
}, { partial: true })

Why partial updates?

DELETE

async delete(params, options = {}) {
  // Simple operation but important

Input example:

api.resources.users.delete(123)

What happens:

  1. beforeDelete hooks (can cancel)
  2. Check if resource exists
  3. Delete from storage
  4. afterDelete hooks (cleanup related data)

Global API Registry

The API supports global registration for easy access across your application:

// Register your API globally
const api = new Api({ name: 'myapp', version: '1.0.0' });
api.register();

// Access from anywhere in your app
const api = Api.get('myapp');           // Gets latest version
const v1 = Api.get('myapp', '1.0.0');   // Gets specific version

// Check what's registered
Api.registry.has('myapp', '1.0.0');     // true
Api.registry.versions('myapp');          // ['1.0.0', '2.0.0']
Api.registry.getAllVersions('myapp');    // [api1, api2]

Why use the registry?

  1. No prop drilling - Access API from any module
  2. Version management - Run multiple API versions
  3. Testing - Easy to mock/replace APIs
  4. Modularity - Plugins can find APIs by name

The Include Parameter

The include parameter replaces the old joins parameter and provides a more flexible way to include related resources:

// Simple include
const user = await api.resources.users.get(123, { 
  include: 'profile' 
});

// Multiple includes (comma-separated)
const post = await api.resources.posts.get(456, { 
  include: 'author,category,tags' 
});

// Nested includes (dot notation)
const comment = await api.resources.comments.get(789, { 
  include: 'author.profile,post.category' 
});

// In queries
const posts = await api.resources.posts.query({
  filter: { published: true },
  include: 'author.profile,category'
});

How includes work:

  1. Permission checks - Each level checks permissions
    // Schema with include permissions
    authorId: {
      type: 'id',
      refs: { resource: 'users' },
      permissions: {
        read: true,              // Anyone can see the ID
        include: 'authenticated' // Must be logged in to include user data
      }
    }
    
  2. Selective field loading
    // Schema with field selection
    authorId: {
      type: 'id',
      refs: {
        resource: 'users',
        join: {
          fields: ['id', 'name', 'avatar'] // Only load these fields
        }
      }
    }
    
  3. Eager loading
    // Always include this relationship
    profileId: {
      type: 'id',
      refs: {
        resource: 'profiles',
        join: {
          eager: true // No need to specify in include parameter
        }
      }
    }
    
  4. Max depth protection
    • Default limit is 3 levels deep
    • Prevents infinite recursion
    • Configure with maxIncludeDepth option

Error Handling

The API uses custom error classes:

class ApiError extends Error {
  constructor(message, statusCode = 500, context = {}) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.context = context;
  }
}

Error types:

Why custom errors?

try {
  await api.resources.users.get(999);
} catch (error) {
  if (error instanceof NotFoundError) {
    // Handle missing user
  } else if (error instanceof ValidationError) {
    // Show validation errors to user
  } else {
    // Unexpected error
  }
}

Core Concepts

Schema

The Schema class (lib/schema.js) defines your data structure:

const userSchema = new Schema({
  id: { type: 'id' },
  name: { 
    type: 'string', 
    required: true,
    min: 2,        // Minimum length
    max: 100       // Maximum length
  },
  email: {
    type: 'string',
    required: true,
    pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/  // Email regex
  },
  age: {
    type: 'number',
    min: 0,
    max: 150
  },
  isActive: {
    type: 'boolean',
    default: true    // Default value if not provided
  },
  password: {
    type: 'string',
    silent: true     // Never return in responses
  },
  metadata: {
    type: 'object'   // Can store any object
  },
  tags: {
    type: 'array',   // Array of any values
    default: []
  },
  role: {
    type: 'string',
    enum: ['user', 'admin', 'moderator']  // Must be one of these
  },
  profileId: {
    type: 'id',
    refs: {          // Relationship to another resource
      resource: 'profiles',
      join: {
        eager: true  // Always include profile data
      }
    }
  }
});

Field types:

Why use schemas?

  1. Validation - Catch errors before they hit the database
  2. Documentation - Self-documenting API
  3. Type safety - Know what to expect
  4. Security - Prevent SQL injection, hide sensitive fields

Advanced Schema Features

Field-Level Permissions

Control who can read, write, or include related data:

const postSchema = new Schema({
  title: { type: 'string', required: true },
  content: { type: 'string' },
  authorEmail: {
    type: 'string',
    permissions: {
      read: 'authenticated',      // Only logged-in users can see
      write: ['admin', 'owner']   // Only admins or owner can modify
    }
  },
  authorId: {
    type: 'id',
    refs: { resource: 'users' },
    permissions: {
      read: true,                 // Anyone can see the ID
      include: 'authenticated'    // Must be logged in to include user data
    }
  }
});

Searchable Fields

Enable filtering on specific fields:

const userSchema = new Schema({
  name: { type: 'string', searchable: true },
  email: { type: 'string', searchable: true },
  password: { type: 'string', silent: true }, // Never searchable
  status: { type: 'string', searchable: true }
});

// Now you can filter:
// GET /api/users?filter[name]=John
// GET /api/users?filter[status]=active

Advanced Relationships

const authorSchema = new Schema({
  name: { type: 'string' },
  // To-one relationship with advanced options
  countryId: {
    type: 'id',
    refs: {
      resource: 'countries',
      join: {
        eager: true,              // Auto-include
        fields: ['id', 'name'],   // Select specific fields
        preserveId: true,         // Keep both ID and object
        resourceField: 'country'  // Put joined data in 'country' field
      }
    }
  },
  // To-many relationship (virtual field)
  posts: {
    type: 'list',
    virtual: true,                // Not stored in database
    foreignResource: 'posts',
    foreignKey: 'authorId',       // Field in posts that references this
    defaultFilter: { published: true },
    defaultSort: '-createdAt'
  }
});

Virtual Fields

Computed fields that aren’t stored:

const orderSchema = new Schema({
  price: { type: 'number' },
  tax: { type: 'number' },
  profit: { 
    type: 'number', 
    virtual: true  // Computed, not stored
  }
});

// Calculate in hooks
api.hook('afterGet', async (context) => {
  if (context.type === 'orders' && context.result) {
    context.result.profit = context.result.price - context.result.tax;
  }
});

Format Validation

Safe, built-in format validators:

const userSchema = new Schema({
  email: { type: 'string', format: 'email' },
  website: { type: 'string', format: 'url' },
  slug: { type: 'string', format: 'slug' },
  uuid: { type: 'string', format: 'uuid' }
});

Security Limits

Prevent abuse with size limits:

const postSchema = new Schema({
  tags: { 
    type: 'array',
    maxItems: 100  // Maximum 100 tags
  },
  metadata: { 
    type: 'object',
    maxKeys: 50,   // Maximum 50 properties
    maxDepth: 5    // Maximum nesting depth
  }
});

### Query Builder

The QueryBuilder (`lib/query-builder.js`) constructs SQL queries:

```javascript
const query = new QueryBuilder('users')
  .select(['id', 'name', 'email'])
  .where('age', '>=', 18)
  .where('isActive', '=', true)
  .orderBy('name', 'ASC')
  .limit(10)
  .offset(20);

console.log(query.toSQL());
// SELECT id, name, email FROM users 
// WHERE age >= ? AND isActive = ? 
// ORDER BY name ASC 
// LIMIT 10 OFFSET 20

console.log(query.getParams());
// [18, true]

Why use a query builder?

  1. Security - Prevents SQL injection with parameterized queries
  2. Portability - Works with different databases
  3. Composability - Build complex queries step by step

Plugin System

How Plugins Work

A plugin is just an object with an install method:

const MyPlugin = {
  install(api, options) {
    // Add functionality to the api
    api.hook('beforeInsert', async (context) => {
      console.log('Inserting:', context.data);
    });
  }
};

Plugin Types

  1. Storage Plugins - Where data is saved
  2. Feature Plugins - Add functionality
  3. Middleware Plugins - Modify requests/responses

Storage Plugins

Memory Plugin (plugins/memory.js)

This stores data in memory using AlaSQL (an in-memory SQL database).

export const MemoryPlugin = {
  install(api, options) {
    const adapter = new AlaSQL();
    
    // Implement storage methods
    api.implement('insert', async (context) => {
      const { type, data } = context;
      
      // Generate ID if needed
      if (!data[api.options.idProperty]) {
        data[api.options.idProperty] = generateId();
      }
      
      // Insert into memory database
      const query = `INSERT INTO ${type} VALUES ?`;
      adapter.exec(query, [data]);
      
      return data;
    });

When to use Memory storage:

Example data flow:

// Input
api.resources.users.create({ name: 'John' })

// Storage receives
{
  type: 'users',
  data: { name: 'John' }
}

// Storage adds ID
{
  type: 'users', 
  data: { id: '123', name: 'John' }
}

// Saved in memory as
memory.users = [
  { id: '123', name: 'John' }
]

MySQL Plugin (plugins/mysql.js)

This stores data in a MySQL database:

export const MySQLPlugin = {
  install(api, options) {
    // Create connection pool
    const pool = mysql.createPool({
      host: options.host,
      user: options.user,
      password: options.password,
      database: options.database,
      waitForConnections: true,
      connectionLimit: 10
    });

Connection pooling:

Schema synchronization:

async function syncSchema(api, connection, type, schema) {
  // 1. Check if table exists
  const [tables] = await connection.query(
    'SHOW TABLES LIKE ?', [type]
  );
  
  if (tables.length === 0) {
    // 2. Create table
    const sql = generateCreateTable(type, schema);
    await connection.query(sql);
  } else {
    // 3. Update table structure
    await updateTableSchema(connection, type, schema);
  }
}

Why sync schemas?

Example MySQL operations:

// INSERT
const query = new QueryBuilder(type)
  .insert(data)
  .toSQL();
// INSERT INTO users (name, email) VALUES (?, ?)

// SELECT with joins
const query = new QueryBuilder('posts')
  .select(['posts.*', 'users.name as authorName'])
  .join('users', 'posts.authorId', 'users.id')
  .where('posts.published', '=', true)
  .toSQL();
// SELECT posts.*, users.name as authorName 
// FROM posts 
// JOIN users ON posts.authorId = users.id 
// WHERE posts.published = ?

SQL Generic Plugin (plugins/sql-generic.js)

This is the base for all SQL storage plugins. It handles:

  1. Query transformation - Convert REST filters to SQL
  2. Joins - Automatic relationship loading
  3. Field mapping - Handle nested fields
  4. Search fields - Map search aliases to real fields
function parseFilter(filter, query, searchableFields, basePath = '') {
  for (const [key, value] of Object.entries(filter)) {
    // Handle operators
    if (key.startsWith('$')) {
      handleOperator(key, value, query, basePath);
      continue;
    }
    
    // Handle nested fields
    const actualPath = searchableFields?.[key] || key;
    
    // Handle different value types
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      // Nested operators like { age: { $gte: 18 } }
      parseFilter(value, query, searchableFields, actualPath);
    } else {
      // Simple equality
      query.where(actualPath, '=', value);
    }
  }
}

Virtual fields:

// In searchableFields
searchableFields: {
  search: '*',  // Virtual field marker
  authorName: 'author.name'  // Map to joined field
}

// Handled by
if (actualPath === '*') {
  // Skip SQL generation - handled by hooks
  continue;
}

Feature Plugins

Validation Plugin (plugins/validation.js)

Ensures data is valid before saving:

export const ValidationPlugin = {
  install(api) {
    // Validate before insert
    api.hook('beforeInsert', async (context) => {
      const schema = api.getSchema(context.type);
      if (!schema) return;
      
      const validation = schema.validate(context.data);
      if (!validation.isValid) {
        throw new ValidationError('Validation failed', {
          validationErrors: validation.errors
        });
      }
    }, 5); // Priority 5 - runs early

Validation flow:

// Input
{ name: '', email: 'invalid', age: 200 }

// Schema validation finds
[
  { field: 'name', message: 'Required field' },
  { field: 'email', message: 'Invalid email format' },
  { field: 'age', message: 'Maximum value is 150' }
]

// Throws ValidationError with details

Authorization Plugin (plugins/authorization.js)

Provides role-based access control (RBAC) and permission management:

export const AuthorizationPlugin = {
  install(api, options = {}) {
    // Initialize storage for roles and permissions
    api._auth = {
      roles: new Map(),
      resources: new Map(),
      options: {
        defaultRole: 'user',
        superAdminRole: 'admin',
        ownerField: 'userId',
        requireAuth: true,
        ...options
      }
    };

Key concepts:

  1. Roles and Permissions:
    api.use(AuthorizationPlugin, {
      roles: {
        admin: {
          permissions: '*',  // All permissions
          description: 'Full system access'
        },
        editor: {
          permissions: ['posts.*', 'media.*', 'users.read'],
          description: 'Content management'
        },
        user: {
          permissions: [
            'posts.read',
            'posts.create',
            'posts.update.own',  // Can only update own posts
            'posts.delete.own'   // Can only delete own posts
          ]
        }
      }
    });
    
  2. User Enhancement Bridge:
    api.use(AuthorizationPlugin, {
      // This function connects to YOUR auth system
      enhanceUser: async (user, context) => {
        // Example 1: Load from database
        const roles = await db.query(
          'SELECT role FROM user_roles WHERE user_id = ?',
          [user.id]
        );
        return { ...user, roles: roles.map(r => r.role) };
           
        // Example 2: Roles already in JWT
        return user; // { id: 1, roles: ['editor'] }
           
        // Example 3: Load from Redis cache
        const cached = await redis.get(`user:${user.id}:roles`);
        return { ...user, roles: JSON.parse(cached) };
      }
    });
    
  3. Resource-Specific Rules:
    api.use(AuthorizationPlugin, {
      resources: {
        posts: {
          ownerField: 'authorId',     // Which field identifies owner
          public: ['read'],            // No auth required
          authenticated: ['create'],   // Any logged-in user
          owner: ['update', 'delete'], // Only owner (checks .own permission)
          permissions: {               // Custom permission mapping
            publish: 'posts.moderate',
            feature: 'posts.admin'
          }
        }
      }
    });
    

Authorization Flow:

  1. HTTP Request arrives:
    // HTTPPlugin extracts user from request
    api.use(HTTPPlugin, {
      getUserFromRequest: (req) => req.user // From your auth middleware
    });
    
  2. User Enhancement:
    // Original user from auth middleware
    { id: 1, email: 'john@example.com' }
       
    // After enhancement
    {
      id: 1,
      email: 'john@example.com',
      roles: ['editor'],
      permissions: ['posts.feature'],
      // Helper methods added
      can: (permission) => boolean,
      hasRole: (role) => boolean,
      hasAnyRole: (...roles) => boolean,
      hasAllRoles: (...roles) => boolean
    }
    
  3. Permission Checking in Hooks:
    // The plugin adds these hooks automatically (priority 10)
    beforeInsert  checks 'create' permission
    beforeGet     checks 'read' permission
    beforeQuery   checks 'read' permission
    beforeUpdate  checks 'update' permission
    beforeDelete  checks 'delete' permission
    
  4. Ownership Checking:
    // User tries to update post 123
    await api.resources.posts.update(123, { title: 'New' }, { user });
       
    // AuthorizationPlugin flow:
    // 1. Check if user has 'posts.update' → No
    // 2. Check if resource allows owner updates → Yes
    // 3. Check if user has 'posts.update.own' → Yes
    // 4. Fetch post 123 to check ownership
    // 5. Compare post.authorId === user.id
    // 6. Allow or deny based on ownership
    
  5. Field-Level Permissions:
    const schema = new Schema({
      title: { type: 'string' },
      content: { type: 'string' },
      internalNotes: {
        type: 'string',
        permission: 'posts.moderate'  // Only visible to moderators
      }
    });
       
    // In transformResult hook:
    // If user lacks 'posts.moderate' permission
    // Remove internalNotes from response
    

Permission Syntax:

// Exact match
'posts.create'         // Can create posts

// Wildcard - all actions
'posts.*'              // All post permissions

// Ownership suffix
'posts.update.own'     // Can update own posts only

// Super wildcard
'*'                    // All permissions (admin)

Integration Example:

// 1. Express authentication middleware
app.use(async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (token) {
    const payload = jwt.verify(token, SECRET);
    req.user = { id: payload.sub, email: payload.email };
  }
  next();
});

// 2. Authorization plugin enhances the user
api.use(AuthorizationPlugin, {
  enhanceUser: async (user) => {
    const roles = await getUserRoles(user.id);
    return { ...user, roles };
  }
});

// 3. HTTP plugin passes user to API
api.use(HTTPPlugin, {
  getUserFromRequest: (req) => req.user
});

// 4. API operations check permissions automatically
// This will throw ForbiddenError if user can't update
await api.resources.posts.update(123, { title: 'New' });

Custom Permission Checks:

api.hook('beforeUpdate', async (context) => {
  if (context.options.type !== 'posts') return;
  
  const user = context.options.user;
  const { status } = context.data;
  
  // Only editors can publish
  if (status === 'published' && !user.can('posts.publish')) {
    throw new ForbiddenError('Cannot publish posts');
  }
});

Why this design?

Timestamps Plugin (plugins/timestamps.js)

Automatically adds created/updated timestamps:

export const TimestampsPlugin = {
  install(api, options = {}) {
    const {
      createdField = 'createdAt',
      updatedField = 'updatedAt',
      format = 'date'  // 'date' or 'unix'
    } = options;
    
    // Add timestamp on insert
    api.hook('beforeInsert', async (context) => {
      const now = format === 'unix' 
        ? Math.floor(Date.now() / 1000)
        : new Date();
        
      context.data[createdField] = now;
      context.data[updatedField] = now;
    });

Example:

// Before insert
{ name: 'John' }

// After timestamps plugin
{
  name: 'John',
  createdAt: '2024-01-20T10:30:00Z',
  updatedAt: '2024-01-20T10:30:00Z'
}

Positioning Plugin (plugins/positioning.js)

Manages item order/position:

export const PositioningPlugin = {
  install(api, options = {}) {
    const {
      field = 'position',      // Field name
      groupBy = null,          // Group positions by field
      startAt = 0,            // First position
      increment = 100         // Gap between positions
    } = options;

Why increment by 100? Allows inserting items between existing ones without reordering everything:

Item A: position = 100
Item B: position = 200
Item C: position = 300

// Insert between A and B
Item D: position = 150

Bulk position shifting:

// When inserting at position 150
// Shift all items >= 150 up by increment
UPDATE items 
SET position = position + 100 
WHERE position >= 150

HTTP Plugin (plugins/http.js)

Creates REST endpoints using Express:

export const HTTPPlugin = {
  install(api, options) {
    const { app, middleware = [] } = options;
    
    // Create routes for each resource
    api.hook('afterAddResource', (context) => {
      const { type } = context;
      const base = `${api.options.apiRoot}/${type}`;
      
      // GET /users (list)
      app.get(base, ...middleware, async (req, res, next) => {
        try {
          const result = await api.query({
            type,
            ...parseQueryParams(req.query)
          });
          res.json(result);
        } catch (error) {
          next(error);
        }
      });
      
      // GET /users/:id (single)
      app.get(`${base}/:id`, ...middleware, async (req, res, next) => {
        try {
          const result = await api.get({
            type,
            id: req.params.id
          });
          res.json(result);
        } catch (error) {
          next(error);
        }
      });

URL to query translation:

GET /users?filter[age][$gte]=18&sort=-createdAt&page[limit]=10

Becomes:
{
  type: 'users',
  filter: { age: { $gte: 18 } },
  sort: ['-createdAt'],
  page: { limit: 10 }
}

Versioning Plugin (plugins/versioning.js)

Tracks changes to records:

export const VersioningPlugin = {
  install(api, options = {}) {
    const {
      versionField = 'version',
      historyResource = null  // Store old versions here
    } = options;
    
    // Increment version on update
    api.hook('beforeUpdate', async (context) => {
      const current = await api.get({
        type: context.type,
        id: context.id
      });
      
      // Save to history if configured
      if (historyResource) {
        await api.insert({
          type: historyResource,
          data: {
            ...current.data.attributes,
            originalId: context.id,
            versionedAt: new Date()
          }
        });
      }
      
      // Increment version
      context.data[versionField] = (current.data.attributes[versionField] || 0) + 1;
    });

Version tracking example:

// Original
{ id: 1, name: 'John', version: 1 }

// After update
{ id: 1, name: 'John Doe', version: 2 }

// History table
[
  { originalId: 1, name: 'John', version: 1, versionedAt: '...' }
]

Soft Delete Plugin (plugins/soft-delete.js)

Marks records as deleted instead of removing them:

export const SoftDeletePlugin = {
  install(api, options = {}) {
    const {
      field = 'deletedAt',
      exclude = true  // Exclude from queries by default
    } = options;
    
    // Override delete to soft delete
    api.hook('beforeDelete', async (context) => {
      // Update instead of delete
      await api.update({
        type: context.type,
        id: context.id,
        data: { [field]: new Date() }
      });
      
      // Prevent actual deletion
      return false;
    });
    
    // Filter out soft deleted records
    if (exclude) {
      api.hook('beforeQuery', async (context) => {
        context.params.filter = {
          ...context.params.filter,
          [field]: { $null: true }
        };
      });
    }

Why soft delete?

How Everything Fits Together

Let’s trace a complete request:

Example: Creating a User

// 1. Your code
const user = await api.resources.users.create({
  name: 'John Doe',
  email: 'john@example.com'
});

Step-by-step flow:

  1. Resource proxy converts to internal call:
    api.insert({ 
      type: 'users', 
      data: { name: 'John Doe', email: 'john@example.com' }
    })
    
  2. Parameter validation checks required params

  3. Create context object:
    {
      type: 'users',
      data: { name: 'John Doe', email: 'john@example.com' },
      options: {},
      api: api,
      config: { /* resource config */ }
    }
    
  4. Run beforeInsert hooks (in priority order):
    • ValidationPlugin (priority 5): Validates against schema
    • TimestampsPlugin (priority 10): Adds createdAt, updatedAt
    • Your custom hooks: Maybe lowercase email, hash password
  5. Execute storage insert:
    • MySQLPlugin: Generates SQL, executes query
    • Returns: { id: 123, name: 'John Doe', email: 'john@example.com', createdAt: '...', updatedAt: '...' }
  6. Run afterInsert hooks:
    • Maybe send welcome email
    • Update search index
    • Clear cache
  7. Format response as JSON:API:
    {
      data: {
        type: 'users',
        id: '123',
        attributes: {
          name: 'John Doe',
          email: 'john@example.com',
          createdAt: '2024-01-20T10:30:00Z',
          updatedAt: '2024-01-20T10:30:00Z'
        }
      }
    }
    

Example: Complex Query

// Find active Gmail users who joined this year, with their profiles
const users = await api.resources.users.query({
  filter: {
    email: { $like: '%@gmail.com' },
    isActive: true,
    createdAt: { $gte: '2024-01-01' }
  },
  joins: ['profile'],
  sort: ['-createdAt'],
  page: { limit: 10 }
});

Processing steps:

  1. beforeQuery hooks might add default filters

  2. SQL generation:
    SELECT 
      users.*,
      profiles.id as profile__id,
      profiles.bio as profile__bio
    FROM users
    LEFT JOIN profiles ON users.profileId = profiles.id
    WHERE 
      users.email LIKE ? 
      AND users.isActive = ?
      AND users.createdAt >= ?
    ORDER BY users.createdAt DESC
    LIMIT 10
    
  3. Execute query with params: ['%@gmail.com', true, '2024-01-01']

  4. Transform results:
    // Raw from database
    [{
      id: 123,
      name: 'John',
      email: 'john@gmail.com',
      profileId: 456,
      profile__id: 456,
      profile__bio: 'Developer'
    }]
       
    // Transformed to
    [{
      id: '123',
      name: 'John',
      email: 'john@gmail.com',
      profile: {
        id: '456',
        bio: 'Developer'
      }
    }]
    
  5. Format as JSON:API with pagination metadata

Plugin Interaction Example

Let’s see how multiple plugins work together:

// Setup
api.use(MySQLPlugin, { /* connection */ });
api.use(ValidationPlugin);
api.use(TimestampsPlugin);
api.use(SoftDeletePlugin);

// Add users with required email
api.addResource('users', new Schema({
  id: { type: 'id' },
  email: { type: 'string', required: true },
  name: { type: 'string' }
}));

// Create a user
await api.resources.users.create({ name: 'John' });
// ValidationPlugin: Throws error - email is required!

// Create with email
await api.resources.users.create({ 
  name: 'John',
  email: 'john@example.com'
});
// ValidationPlugin: ✓ Valid
// TimestampsPlugin: Adds createdAt, updatedAt
// MySQLPlugin: INSERT INTO users ...

// Delete the user
await api.resources.users.delete(123);
// SoftDeletePlugin: Intercepts, sets deletedAt instead

// Query users
await api.resources.users.query({});
// SoftDeletePlugin: Adds filter { deletedAt: null }
// Returns only non-deleted users

Best Practices

  1. Always use resource proxies for cleaner code:
    // Good
    api.resources.users.get(123)
       
    // Less clean
    api.get({ type: 'users', id: 123 })
    
  2. Install plugins in order:
    // Storage first
    api.use(MySQLPlugin, config);
       
    // Then features
    api.use(ValidationPlugin);
    api.use(TimestampsPlugin);
    
  3. Use appropriate hook priorities:
    // Validation should run early
    api.hook('beforeInsert', validateData, 5);
       
    // Timestamps can run later
    api.hook('beforeInsert', addTimestamps, 10);
    
  4. Handle errors properly:
    try {
      await api.resources.users.get(id);
    } catch (error) {
      if (error instanceof NotFoundError) {
        // Handle missing user
      } else {
        // Unexpected error
        throw error;
      }
    }
    
  5. Use schemas for validation:
    // Define constraints in schema
    new Schema({
      email: { 
        type: 'string', 
        required: true,
        pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
      }
    })
       
    // Not in application code
    if (!email || !email.includes('@')) { ... }
    

Detailed Plugin Analysis

Let’s dive deeper into each plugin’s implementation:

Logging Plugin (plugins/logging.js)

This plugin provides structured logging with security features:

export const LoggingPlugin = {
  install(api, options = {}) {
    const defaultOptions = {
      level: process.env.LOG_LEVEL || 'info',
      format: 'json', // 'json' or 'pretty'
      includeRequest: true,
      includeResponse: true,
      includeTiming: true,
      sensitiveFields: ['password', 'token', 'secret', 'authorization'],
      logger: console, // Can be replaced with winston, bunyan, etc.
      ...options
    };

Key features:

  1. Structured logging with levels:
    api.log.info('User created', { userId: 123, email: 'john@example.com' });
    api.log.error('Database connection failed', { error: err.message });
    api.log.debug('SQL query', { sql: query, duration: 45 });
    
  2. Automatic sensitive data redaction:
    // Input
    { name: 'John', password: 'secret123', email: 'john@example.com' }
       
    // Logged as
    { name: 'John', password: '[REDACTED]', email: 'john@example.com' }
    
  3. Performance timing:
    // Automatically tracks operation duration
    {
      operation: 'get',
      type: 'users',
      duration: 45, // milliseconds
      requestId: 'abc123'
    }
    
  4. Request tracking with IDs:
    // Each request gets a unique ID for tracing
    api.hook('beforeValidate', async (context) => {
      context.options.requestId = generateId();
    });
    

Example log output:

{
  "timestamp": "2024-01-20T10:30:00.123Z",
  "level": "info",
  "message": "GET users completed",
  "operation": "get",
  "type": "users",
  "id": "123",
  "requestId": "x7h3k9",
  "userId": "456",
  "duration": 23,
  "resultCount": 1
}

Why this approach?

Security Plugin (plugins/security.js)

Comprehensive security features following OWASP best practices:

export const SecurityPlugin = {
  install(api, options = {}) {
    const defaultOptions = {
      rateLimit: {
        windowMs: 15 * 60 * 1000, // 15 minutes
        max: 100, // limit each IP to 100 requests per windowMs
        message: 'Too many requests from this IP'
      },
      cors: {
        origin: '*',
        credentials: true,
        methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
        allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
        exposedHeaders: ['X-Total-Count', 'Link']
      },
      authentication: {
        type: 'bearer', // 'bearer', 'basic', 'apikey'
        header: 'Authorization',
        queryParam: 'api_key'
      }
    };

Security features implemented:

  1. Complete HTTP Security Headers:
    // Automatically adds these headers to all responses:
    'X-Content-Type-Options': 'nosniff'              // Prevent MIME sniffing
    'X-Frame-Options': 'DENY'                        // Prevent clickjacking
    'X-XSS-Protection': '1; mode=block'              // Enable XSS filter
    'Strict-Transport-Security': 'max-age=31536000'  // Force HTTPS
    'Content-Security-Policy': "default-src 'self'"  // Control resources
    
  2. Rate Limiting with Headers:
    // Response includes rate limit info
    X-RateLimit-Limit: 100
    X-RateLimit-Remaining: 87
    X-RateLimit-Reset: 2024-01-20T11:45:00Z
       
    // After limit exceeded
    {
      "errors": [{
        "status": "429",
        "title": "Too Many Requests",
        "detail": "Too many requests from this IP"
      }]
    }
    
  3. Multiple Authentication Methods:
    // Bearer Token (JWT)
    Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
       
    // API Key (header or query)
    X-API-Key: sk_live_abc123
    // or
    GET /api/users?api_key=sk_live_abc123
       
    // Basic Auth
    Authorization: Basic dXNlcjpwYXNz
    
  4. Input Sanitization:
    // Automatically escapes dangerous characters
    Input:  { name: "<script>alert('XSS')</script>" }
    Stored: { name: "&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;" }
    
  5. Request Tracking:
    // Every request gets a unique ID
    X-Request-ID: req_1705751400123_a8b2c3d4e
       
    // Use for debugging across services
    api.log.error('Failed to process', { requestId: req.id });
    

Configuration example:

api.use(SecurityPlugin, {
  rateLimit: {
    windowMs: 5 * 60 * 1000,  // 5 minutes
    max: 50                   // 50 requests per window
  },
  authentication: {
    type: 'bearer',
    required: true
  },
  verifyToken: async (token) => {
    // Your token verification logic
    const user = await jwt.verify(token, process.env.JWT_SECRET);
    return user;
  },
  publicRead: true  // Allow GET without auth
});

Why these features?

Timestamps Plugin (plugins/timestamps.js)

Automatically manages created and updated timestamps:

export const TimestampsPlugin = {
  install(api, options = {}) {
    const {
      createdAtField = 'createdAt',
      updatedAtField = 'updatedAt',
      touchOnGet = false,  // Update timestamp on read
      format = 'timestamp'  // 'timestamp', 'date', 'dateTime'
    } = options;

Key features:

  1. Automatic schema enhancement:
    // Your schema
    { id: { type: 'id' }, name: { type: 'string' } }
       
    // After plugin adds fields
    {
      id: { type: 'id' },
      name: { type: 'string' },
      createdAt: { type: 'number' },  // Added automatically
      updatedAt: { type: 'number' }   // Added automatically
    }
    
  2. Multiple time formats:
    // timestamp (default) - milliseconds since epoch
    { createdAt: 1705751400000, updatedAt: 1705751400000 }
       
    // date - YYYY-MM-DD
    { createdAt: '2024-01-20', updatedAt: '2024-01-20' }
       
    // dateTime - ISO 8601
    { createdAt: '2024-01-20T10:30:00.000Z', updatedAt: '2024-01-20T10:30:00.000Z' }
    
  3. Smart delay for uniqueness:
    // Adds 1-2ms delay to ensure timestamps differ
    // Important for tests and rapid operations
    
  4. Touch functionality:
    // Manual touch
    await api.touchRecord('posts', 123);
       
    // Auto-touch on read (if enabled)
    api.use(TimestampsPlugin, { touchOnGet: true });
    await api.resources.posts.get(123); // Updates updatedAt
    

Why timestamps matter:

Validation Plugin (plugins/validation.js)

Advanced validation with error codes and permissions:

export const ValidationPlugin = {
  install(api, options = {}) {
    // Initialize schemas map
    if (!api.schemas) {
      api.schemas = new Map();
    }

    // Add validation hooks
    api.hook('beforeValidate', async (context) => {
      const schema = api.schemas.get(type);
      
      // Validate with proper error codes
      const mappedErrors = errors.map(err => {
        let code = ErrorCodes.INVALID_VALUE;
        
        if (err.message.includes('required')) {
          code = ErrorCodes.REQUIRED_FIELD;
        } else if (err.message.includes('too long')) {
          code = ErrorCodes.FIELD_TOO_LONG;
        }
        // ... more mappings
      });
    });

Key features:

  1. Structured error codes:
    {
      field: 'email',
      message: 'Invalid email format',
      code: 'INVALID_FORMAT',
      value: 'not-an-email'
    }
    
  2. Partial validation for updates:
    // Only validate provided fields on PATCH
    api.resources.users.update(123, { age: 25 }, { partial: true });
    
  3. Search parameter validation:
    // Create search schema from main schema
    const searchSchema = api.createSearchSchema(userSchema, ['name', 'email']);
       
    // Validates query parameters
    api.resources.users.query({
      filter: { email: 'invalid' }  // Will be validated
    });
    
  4. Permission checking:
    api.use(ValidationPlugin, {
      checkPermissions: async (context) => {
        const user = context.options.user;
        if (context.method === 'delete' && !user.isAdmin) {
          return { granted: false, message: 'Only admins can delete' };
        }
        return { granted: true };
      }
    });
    
  5. Field cleanup:
    // Remove fields not in schema
    // Remove silent fields from responses
    api.use(ValidationPlugin, {
      cleanupFields: true,
      removeSilentFields: true
    });
    

Positioning Plugin (plugins/positioning.js)

Manages ordered lists with automatic position assignment:

export const PositioningPlugin = {
  install(api, options = {}) {
    const defaultOptions = {
      positionField: 'position',
      beforeIdField: 'beforeId',
      positionFilters: [],
      ...options
    };

Key features:

  1. Automatic position assignment:
    // First item gets position 1
    await api.resources.todos.create({ title: 'First' });
    // { id: 1, title: 'First', position: 1 }
       
    // Next item gets position 2
    await api.resources.todos.create({ title: 'Second' });
    // { id: 2, title: 'Second', position: 2 }
    
  2. Insert before specific item:
    // Insert between items
    await api.resources.todos.create({
      title: 'New',
      beforeId: 2  // Insert before item 2
    });
    // Automatically shifts positions:
    // Item 1: position 1
    // New item: position 2
    // Item 2: position 3 (shifted)
    
  3. Position groups:
    // Configure grouped positioning
    api.addResource('tasks', schema, {
      positioning: {
        field: 'position',
        groupBy: ['projectId', 'status']  // Separate positions per group
      }
    });
       
    // Each project+status combo has its own sequence
    await api.resources.tasks.create({ 
      projectId: 1, 
      status: 'todo',
      title: 'Task 1'
    }); // position: 1 in project 1, todo
       
    await api.resources.tasks.create({ 
      projectId: 1, 
      status: 'done',
      title: 'Task 2'
    }); // position: 1 in project 1, done (different group)
    
  4. Bulk position operations:
    // Efficiently shifts many records
    await api.shiftPositions('todos', {
      field: 'position',
      from: 5,      // Shift items from position 5
      delta: 2,     // Move them up by 2
      filter: { projectId: 1 }
    });
    
  5. Position normalization:
    // Remove gaps in positions
    await api.normalizePositions('todos', { projectId: 1 });
    // Before: [1, 3, 7, 8]
    // After:  [1, 2, 3, 4]
    

Why positioning matters:

HTTP Plugin (plugins/http.js)

Creates Express routes with JSON:API compliance:

export const HTTPPlugin = {
  install(api, options = {}) {
    const router = express.Router();
    api.router = router;

    // Middleware for JSON parsing
    router.use(express.json({
      type: ['application/json', 'application/vnd.api+json']
    }));

Key features:

  1. Automatic route creation:
    api.addResource('posts', schema);
       
    // Creates these routes:
    GET    /api/posts        // List
    GET    /api/posts/:id    // Get one
    POST   /api/posts        // Create
    PATCH  /api/posts/:id    // Update
    DELETE /api/posts/:id    // Delete
    
  2. JSON:API request/response format:
    // Request
    POST /api/posts
    {
      "data": {
        "type": "posts",
        "attributes": {
          "title": "Hello World",
          "content": "..."
        }
      }
    }
       
    // Response
    {
      "data": {
        "type": "posts",
        "id": "123",
        "attributes": {
          "title": "Hello World",
          "content": "..."
        }
      }
    }
    
  3. Query parameter parsing:
    GET /api/posts?
      filter[status]=published&
      filter[authorId]=123&
      sort=-createdAt,title&
      page[size]=10&
      page[number]=2&
      joins=author,comments&
      fields[posts]=title,content
       
    // Parsed to:
    {
      filter: { status: 'published', authorId: '123' },
      sort: [
        { field: 'createdAt', direction: 'DESC' },
        { field: 'title', direction: 'ASC' }
      ],
      page: { size: 10, number: 2 },
      joins: ['author', 'comments'],
      fields: { posts: ['title', 'content'] }
    }
    
  4. Compound documents with includes:
    GET /api/posts/123?include=author,comments
       
    {
      "data": { /* post */ },
      "included": [
        { "type": "users", "id": "456", /* author */ },
        { "type": "comments", "id": "789", /* comment */ }
      ]
    }
    
  5. Error handling:
    // Validation errors
    {
      "errors": [{
        "status": "422",
        "source": { "pointer": "/data/attributes/email" },
        "title": "Validation Error",
        "detail": "Email format is invalid"
      }]
    }
    

Versioning Plugin (plugins/versioning.js)

Manages API versions and resource versions:

export const VersioningPlugin = {
  install(api, options = {}) {
    const defaultOptions = {
      // API versioning
      apiVersion: '1.0.0',
      versionHeader: 'api-version',
      
      // Resource versioning
      versionField: 'version',
      lastModifiedField: 'lastModified',
      trackHistory: false
    };

Key features:

  1. API version negotiation:
    // Via header
    GET /api/users
    API-Version: 2.0.0
       
    // Via query param
    GET /api/users?v=2.0.0
       
    // Response includes version
    API-Version: 1.0.0
    
  2. Optimistic concurrency control:
    // Get resource with version
    const post = await api.resources.posts.get(123);
    // { id: 123, title: 'Old', version: 1 }
       
    // Update with version check
    await api.resources.posts.update(123, {
      title: 'New',
      version: 1  // Expected version
    });
       
    // If someone else updated (version now 2), throws ConflictError
    
  3. Automatic version increment:
    // Before update: { id: 1, title: 'Hello', version: 1 }
    await api.resources.posts.update(1, { title: 'Hi' });
    // After update: { id: 1, title: 'Hi', version: 2 }
    
  4. History tracking:
    api.use(VersioningPlugin, {
      trackHistory: true,
      historyTable: 'posts_history'
    });
       
    // Updates create history records
    // posts_history table contains all previous versions
    
  5. Modified tracking:
    {
      id: 123,
      title: 'Post',
      version: 3,
      lastModified: '2024-01-20T10:30:00Z',
      modifiedBy: 'user-456'
    }
    

SQL Generic Plugin (plugins/sql-generic.js)

Base plugin that handles SQL query generation for all SQL databases:

// This is the core of how filters become SQL
function parseFilter(filter, query, searchableFields, basePath = '') {
  for (const [key, value] of Object.entries(filter)) {
    // Handle operators like $gte, $like, etc.
    if (key.startsWith('$')) {
      handleOperator(key, value, query, basePath);
      continue;
    }
    
    // Map field names using searchableFields
    const actualPath = searchableFields?.[key] || key;
    
    // IMPORTANT: Handle virtual fields marked with '*'
    if (actualPath === '*') {
      // Skip SQL generation - will be handled by modifyQuery hooks
      continue;
    }
    
    // Generate SQL for real fields
    query.where(actualPath, '=', value);
  }
}

Key responsibilities:

  1. Filter parsing - Converts REST filters to SQL WHERE clauses
  2. Field mapping - Uses searchableFields to map API names to DB columns
  3. Virtual field detection - Skips fields marked with ‘*’
  4. Operator handling - Supports $gte, $like, $in, etc.
  5. Join processing - Handles nested field access like ‘author.name’

Filter operators supported:

// Comparison
filter: { age: { $gte: 18 } }        // age >= 18
filter: { age: { $lt: 65 } }         // age < 65

// Pattern matching  
filter: { email: { $like: '%@gmail.com' } }  // LIKE '%@gmail.com'

// Lists
filter: { status: { $in: ['active', 'pending'] } }     // IN ('active', 'pending')
filter: { role: { $nin: ['banned', 'deleted'] } }      // NOT IN (...)

// Null checks
filter: { deletedAt: { $null: true } }     // IS NULL
filter: { deletedAt: { $notNull: true } }  // IS NOT NULL

// Negation
filter: { status: { $ne: 'deleted' } }     // != 'deleted'

Why this plugin is important:

Memory Plugin (plugins/memory.js)

In-memory storage using AlaSQL:

export const MemoryPlugin = {
  install(api, options = {}) {
    // Creates SQL-compatible in-memory database
    const alasql = new AlaSQL();

When to use:

Features:

MySQL Plugin (plugins/mysql.js)

Production-ready MySQL storage with connection pooling:

export const MySQLPlugin = {
  install(api, options) {
    const pool = mysql.createPool({
      host: options.host,
      user: options.user,
      password: options.password,
      database: options.database,
      waitForConnections: true,
      connectionLimit: 10
    });

Key features:

  1. Automatic schema sync:
    // Your schema
    new Schema({
      id: { type: 'id' },
      email: { type: 'string', unique: true },
      age: { type: 'number', index: true }
    });
       
    // Automatically creates:
    CREATE TABLE users (
      id INT AUTO_INCREMENT PRIMARY KEY,
      email VARCHAR(255) UNIQUE,
      age INT,
      INDEX idx_age (age)
    );
    
  2. Connection pooling:
    • Reuses connections
    • Handles connection failures
    • Configurable pool size
  3. Bulk operations:
    // Efficient bulk position shift
    UPDATE todos 
    SET position = position + 1 
    WHERE position >= 5 AND projectId = 1
    
  4. Transaction support:
    const conn = await api.getConnection();
    await conn.beginTransaction();
    try {
      // Multiple operations
      await conn.commit();
    } catch (error) {
      await conn.rollback();
    }
    

Common Patterns and Recipes

Pattern 1: Multi-tenant System

// Add tenant isolation
api.hook('beforeQuery', async (context) => {
  const tenantId = context.options.tenantId;
  if (!tenantId) {
    throw new Error('Tenant ID required');
  }

  context.params.filter = {
    ...context.params.filter,
    tenantId
  };
});

api.hook('beforeInsert', async (context) => {
  const tenantId = context.options.tenantId;
  if (!tenantId) {
    throw new Error('Tenant ID required');
  }

  context.data.tenantId = tenantId;
});

Pattern 2: Computed Fields

// Add computed fields after fetching
api.hook('afterGet', async (context) => {
  if (context.type === 'users' && context.result) {
    const user = context.result.data.attributes;
    
    // Add computed field
    user.fullName = `${user.firstName} ${user.lastName}`;
    user.age = calculateAge(user.birthDate);
  }
});

Pattern 3: Cascading Deletes

// Delete related records
api.hook('afterDelete', async (context) => {
  if (context.type === 'users') {
    // Delete user's posts
    const posts = await api.resources.posts.query({
      filter: { userId: context.id }
    });

    for (const post of posts.data) {
      await api.resources.posts.delete(post.id);
    }
  }
});

Pattern 4: Data Denormalization

// Update denormalized data
api.hook('afterUpdate', async (context) => {
  if (context.type === 'users') {
    // Update author name in all posts
    await api.execute('db.query', {
      sql: 'UPDATE posts SET authorName = ? WHERE authorId = ?',
      params: [context.result.name, context.id]
    });
  }
});

Pattern 5: Custom Validation

// Business logic validation
api.hook('beforeInsert', async (context) => {
  if (context.type === 'orders') {
    const product = await api.resources.products.get(context.data.productId);
    
    if (product.data.attributes.stock < context.data.quantity) {
      throw new ValidationError('Insufficient stock', {
        validationErrors: [{
          field: 'quantity',
          message: `Only ${product.data.attributes.stock} items available`
        }]
      });
    }
  }
});

Tricky Concepts and Gotchas

Hook Context Mutations

Important: Context objects are passed by reference through all hooks!

// WRONG - Direct mutation affects all subsequent hooks
api.hook('beforeInsert', async (context) => {
  context.data.createdBy = 'system';  // This modifies the original!
});

// CORRECT - For adding fields
api.hook('beforeInsert', async (context) => {
  context.data = { ...context.data, createdBy: 'system' };
});

// ALSO CORRECT - When you intend to modify
api.hook('beforeInsert', async (context) => {
  // Document that this is intentional
  context.data.email = context.data.email.toLowerCase(); // Normalize email
});

Why this matters:

Virtual Field Deletion Requirement

Critical: You MUST delete virtual fields from filter to avoid SQL errors!

// WRONG - Will cause SQL error "Unknown column 'search'"
api.hook('modifyQuery', async (context) => {
  if (context.params.filter?.search) {
    const search = context.params.filter.search;
    context.query.where('title LIKE ?', `%${search}%`);
    // Forgot to delete!
  }
});

// CORRECT - Delete the virtual field
api.hook('modifyQuery', async (context) => {
  if (context.params.filter?.search) {
    const search = context.params.filter.search;
    delete context.params.filter.search;  // CRITICAL!
    context.query.where('title LIKE ?', `%${search}%`);
  }
});

Hook Cancellation Flow

When a hook returns false, here’s what happens:

// Hook returns false
api.hook('beforeInsert', async (context) => {
  if (context.data.protected) {
    return false;  // Cancel operation
  }
});

// Result:
// 1. Remaining beforeInsert hooks DO NOT run
// 2. The insert operation DOES NOT happen
// 3. afterInsert hooks DO NOT run
// 4. The operation returns undefined

Exception: Throwing an error is different:

api.hook('beforeInsert', async (context) => {
  if (context.data.invalid) {
    throw new ValidationError('Invalid data');  // Throws to caller
  }
});
// Error propagates immediately, no cleanup hooks run

Plugin Order Dependencies

Critical plugin ordering:

// CORRECT ORDER
api.use(MySQLPlugin);        // 1. Storage MUST be first
api.use(ValidationPlugin);   // 2. Validates before other plugins modify
api.use(TimestampsPlugin);   // 3. Adds fields after validation
api.use(PositioningPlugin);  // 4. Needs storage for queries
api.use(HTTPPlugin);         // 5. Needs all CRUD operations ready

// WRONG - Will break!
api.use(HTTPPlugin);         // ❌ No storage to handle requests!
api.use(TimestampsPlugin);   // ❌ Fields added before validation!
api.use(MySQLPlugin);        // ❌ Too late!

Schema Type Coercion

The system tries to be helpful but can surprise you:

// Schema expects number
{ age: { type: 'number' } }

// Input variations:
{ age: "25" }      // ✅ Coerced to 25
{ age: "25.5" }    // ✅ Coerced to 25.5
{ age: "twenty" }  // ❌ Validation error
{ age: "" }        // ❌ Validation error (not null)
{ age: null }      // ✅ Allowed if not required
{ age: undefined } // ✅ Treated as not provided

Important distinctions:

Connection Pool Exhaustion

Common mistake that kills production:

// WRONG - Leaks connections!
async function getDataBadly() {
  const results = [];
  for (let i = 0; i < 100; i++) {
    // Each query takes a connection from the pool
    const user = await api.resources.users.get(i);
    results.push(user);
  }
  return results;
  // If pool size is 10, this hangs after 10 iterations!
}

// CORRECT - Use batch operations
async function getDataProperly() {
  const userIds = Array.from({ length: 100 }, (_, i) => i);
  return await api.resources.users.query({
    filter: { id: { $in: userIds } },
    page: { size: 100 }
  });
}

Silent Field Leaks

Silent fields can still leak in unexpected ways:

// Schema with silent password
{ password: { type: 'string', silent: true } }

// DANGER - Password visible in hooks!
api.hook('afterInsert', async (context) => {
  console.log(context.data);  // Includes password!
  await auditLog(context.data);  // Leaked to audit system!
});

// DANGER - Error messages might include values
try {
  await api.resources.users.create({ password: '123' });
} catch (error) {
  // Error context might contain { password: '123' }
  console.log(error.context);  
}

// SAFE - Explicitly exclude silent fields
api.hook('afterInsert', async (context) => {
  const { password, ...safeData } = context.data;
  await auditLog(safeData);
});

Join Performance Cliffs

Eager joins can destroy performance:

// DANGER - N+1 query problem
const posts = await api.resources.posts.query({
  joins: ['author', 'comments', 'comments.author'],  // Deep joins!
  page: { size: 100 }
});
// Might run 100s of queries!

// BETTER - Selective joining
const posts = await api.resources.posts.query({
  joins: ['author'],  // Only what you need
  page: { size: 100 }
});

// BEST - Field selection
const posts = await api.resources.posts.query({
  fields: ['id', 'title', 'author.name'],
  joins: ['author'],
  page: { size: 100 }
});

Transaction Edge Cases

Transactions aren’t automatic:

// WRONG - Not atomic!
await api.resources.accounts.update(1, { balance: 100 });
await api.resources.accounts.update(2, { balance: -100 });
// If second fails, first is still committed!

// CORRECT - Use transactions (MySQL example)
const conn = await api.getConnection();
await conn.beginTransaction();
try {
  await conn.query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 1]);
  await conn.query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 2]);
  await conn.commit();
} catch (error) {
  await conn.rollback();
  throw error;
} finally {
  conn.release();
}

Resource Proxy Memory

Resource proxies are cached - this can surprise you:

// First access creates proxy
const users1 = api.resources.users;  // Creates proxy

// Later access returns SAME proxy
const users2 = api.resources.users;  // Same object!

// This means:
users1 === users2;  // true

// Custom properties persist
users1.myCustomProp = 'test';
console.log(users2.myCustomProp);  // 'test' - Same object!

// If you need fresh state, use the direct API
await api.get({ type: 'users', id: 123 });

SearchableFields Path Resolution

Nested paths have subtle behaviors:

searchableFields: {
  authorName: 'author.name',     // Works if relationship exists
  cityName: 'author.address.city' // Fails if author has no address!
}

// Query with missing relationship
GET /api/posts?filter[cityName]=London
// Returns NO results if any author.address is null
// Not an error, just empty results!

// BETTER - Use left joins and handle nulls
api.hook('modifyQuery', async (context) => {
  if (context.params.filter?.cityName) {
    context.query.leftJoin(...);  // Handle missing relationships
  }
});

Pagination with Joins Gotcha

Joins can cause duplicate counting issues:

// PROBLEM - Count includes duplicates!
const posts = await api.resources.posts.query({
  joins: ['tags'],  // Many-to-many relationship
  page: { size: 10 }
});
// If a post has 3 tags, it appears 3 times in results!
// Page size 10 might only return 3-4 unique posts

// SOLUTION - Use DISTINCT or handle in application
api.hook('modifyQuery', async (context) => {
  if (context.joins?.includes('tags')) {
    context.query.distinct();  // Add DISTINCT
  }
});

Authorization Plugin Gotchas

1. Recursive Permission Checks:

// DANGER - Infinite recursion!
api.hook('beforeUpdate', async (context) => {
  // This triggers another permission check!
  const existing = await api.get(context.id, { type: context.type });
  // Stack overflow!
});

// CORRECT - Use _skipAuth flag
const existing = await api.get(context.id, { 
  type: context.type, 
  _skipAuth: true  // Internal bypass
});

2. User Enhancement Timing:

// WRONG - User not enhanced in early hooks
api.hook('beforeValidate', async (context) => {
  // This runs at priority 5, before authorization
  if (context.options.user.can('admin')) {  // ERROR! .can() doesn't exist yet
    // Skip validation for admins
  }
}, 5);

// CORRECT - Enhance user manually if needed early
api.hook('beforeValidate', async (context) => {
  if (context.options.user) {
    const user = await api.enhanceUserForAuth(context.options.user);
    if (user.can('admin')) {
      // Skip validation for admins
    }
  }
}, 5);

3. Field Permissions Don’t Prevent Writing:

// Schema with permission-protected field
const schema = new Schema({
  title: { type: 'string' },
  internalNotes: { 
    type: 'string',
    permission: 'posts.moderate'  // Only controls READING
  }
});

// DANGER - Users can still WRITE to the field!
await api.resources.posts.create({
  title: 'Post',
  internalNotes: 'Secret!'  // This gets saved!
}, { user: regularUser });

// SOLUTION - Check in hooks
api.hook('beforeInsert', async (context) => {
  if (context.data.internalNotes && !context.options.user?.can('posts.moderate')) {
    delete context.data.internalNotes;
  }
});

4. Ownership Checks on Non-Existent Records:

// User tries to update deleted record
await api.resources.posts.update(999, { title: 'New' }, { user });

// Authorization flow:
// 1. Check permissions
// 2. Fetch record to check ownership
// 3. Record doesn't exist
// 4. Throws NotFoundError (not ForbiddenError!)

// This can leak information about what IDs exist!

5. Permission Inheritance Confusion:

// Wildcards don't work backwards!
user.permissions = ['posts.update.own'];

user.can('posts.update.own');  // ✅ true
user.can('posts.update');       // ❌ false - More specific doesn't grant general
user.can('posts.*');            // ❌ false - No wildcard permission

// But wildcards work forward:
user.permissions = ['posts.*'];
user.can('posts.create');       // ✅ true
user.can('posts.update');       // ✅ true
user.can('posts.update.own');   // ✅ true - Wildcard includes everything

6. Public Operations Still Create Context:

// Resource allows public read
resources: {
  posts: {
    public: ['read']
  }
}

// But hooks still run with no user!
api.hook('afterGet', async (context) => {
  // context.options.user is undefined for public access
  const userName = context.options.user?.name || 'Anonymous';
  await logAccess(userName, context.id);
});

Default Values vs Required Fields

This combination can be confusing:

// Schema
{
  status: { 
    type: 'string', 
    required: true,  // Required...
    default: 'draft' // ...but has default
  }
}

// These all work:
api.resources.posts.create({ title: 'Test' });  // status = 'draft'
api.resources.posts.create({ title: 'Test', status: 'published' });
api.resources.posts.create({ title: 'Test', status: null });  // Error!

// On update, default is NOT applied:
api.resources.posts.update(123, { title: 'New' });  // status unchanged

Hook Priority Tiebreakers

When priorities match, registration order wins:

api.hook('beforeInsert', hookA, 10);  // Runs first
api.hook('beforeInsert', hookB, 10);  // Runs second
api.hook('beforeInsert', hookC, 5);   // Actually runs first! (lower = earlier)

// To guarantee order with same priority:
const hooks = [hookA, hookB, hookC];
hooks.forEach((hook, index) => {
  api.hook('beforeInsert', hook, 10 + (index * 0.1));
});
// Now: hookA=10.0, hookB=10.1, hookC=10.2

Artificial Delay Gotcha

The artificial delay can break connection pools:

const api = new Api({ 
  artificialDelay: 1000  // 1 second delay
});

// DANGER - With pool size 10:
const promises = [];
for (let i = 0; i < 20; i++) {
  promises.push(api.resources.users.get(i));
}
await Promise.all(promises);
// First 10 start, hold connections for 1 second
// Next 10 wait for connections... timeout!

// SOLUTION - Increase pool size or reduce concurrency

Schema Validation vs Database Constraints

These are separate and can conflict:

// Schema says optional
{ email: { type: 'string', required: false } }

// But database has NOT NULL constraint
CREATE TABLE users (email VARCHAR(255) NOT NULL);

// Result:
api.resources.users.create({ name: 'John' });
// ✅ Passes schema validation
// ❌ Database error: Column 'email' cannot be null

// Always keep schema and database in sync!

Error Context vs Error Message

Don’t put sensitive data in error messages:

// WRONG - Password in message
throw new ValidationError(`Invalid password: ${password}`);

// WRONG - Sensitive data in error
throw new ValidationError('Invalid password', {
  context: { password: actualPassword }  // Logs might capture this
});

// CORRECT - Generic message, safe context
throw new ValidationError('Invalid password', {
  context: { 
    field: 'password',
    reason: 'too_short',
    minLength: 8
  }
});

Debugging Tips

1. Enable Debug Mode

const api = new Api({ 
  debug: true,      // General debug
  debugSQL: true    // SQL queries
});

2. Log Hook Execution

// Temporary debugging hook
api.hook('beforeInsert', async (context) => {
  console.log('Insert context:', JSON.stringify(context, null, 2));
});

3. Use the Logging Plugin

api.use(LoggingPlugin, {
  level: 'debug',
  includeData: true,
  includeResult: true
});

4. Check Hook Order

// List all hooks and their priorities
console.log('Hooks:', api._hooks);

5. Test in Isolation

// Test with memory storage first
const testApi = new Api();
testApi.use(MemoryPlugin);
testApi.addResource('test', schema);

// Then switch to real storage

Performance Tips

1. Use Field Selection

// Only fetch needed fields
const users = await api.resources.users.query({
  fields: ['id', 'name', 'email']  // Don't fetch large fields
});

2. Implement Pagination

// Always paginate large datasets
const results = await api.resources.logs.query({
  page: { limit: 50, offset: 0 }
});

3. Use Indexes

// In schema definition
new Schema({
  email: { type: 'string', index: true },
  createdAt: { type: 'date', index: true }
});

4. Cache Frequently Accessed Data

api.use(CachingPlugin, {
  ttl: 600,  // 10 minutes
  cacheGets: true,
  cacheQueries: true
});

5. Batch Operations

// Instead of many individual inserts
const users = [...];
for (const user of users) {
  await api.resources.users.create(user);  // Slow!
}

// Use bulk operations
await api.resources.users.bulkCreate(users);  // Fast!

Full Example: Building a Blog API

Let’s put it all together to build a complete blog API:

import { Api, Schema, MySQLPlugin, ValidationPlugin, TimestampsPlugin, 
         PositioningPlugin, HTTPPlugin, SecurityPlugin, LoggingPlugin } from 'json-rest-api';
import express from 'express';

// 1. Create API instance
const api = new Api({
  idProperty: 'id',
  debug: true
});

// 2. Install plugins in order
api.use(MySQLPlugin, {
  host: 'localhost',
  user: 'root',
  password: 'password',
  database: 'blog'
});

api.use(ValidationPlugin);
api.use(TimestampsPlugin);
api.use(PositioningPlugin);
api.use(SecurityPlugin, {
  rateLimit: { windowMs: 15 * 60 * 1000, max: 100 },
  publicRead: true  // Allow reading without auth
});
api.use(LoggingPlugin, { level: 'info' });

// 3. Define schemas
const userSchema = new Schema({
  id: { type: 'id' },
  username: { type: 'string', required: true, unique: true, min: 3, max: 20 },
  email: { type: 'string', required: true, unique: true },
  password: { type: 'string', required: true, silent: true }, // Never returned
  role: { type: 'string', enum: ['user', 'admin'], default: 'user' }
});

const postSchema = new Schema({
  id: { type: 'id' },
  title: { type: 'string', required: true, max: 200 },
  slug: { type: 'string', unique: true },
  content: { type: 'string', required: true },
  excerpt: { type: 'string', max: 500 },
  status: { type: 'string', enum: ['draft', 'published'], default: 'draft' },
  authorId: { 
    type: 'id', 
    refs: { 
      resource: 'users',
      join: { eager: true, fields: ['id', 'username'] }
    }
  },
  categoryId: { type: 'id', refs: { resource: 'categories' } },
  tags: { type: 'array', default: [] }
});

const commentSchema = new Schema({
  id: { type: 'id' },
  postId: { type: 'id', refs: { resource: 'posts' } },
  userId: { type: 'id', refs: { resource: 'users' } },
  content: { type: 'string', required: true, max: 1000 },
  status: { type: 'string', enum: ['pending', 'approved', 'spam'], default: 'pending' }
});

const categorySchema = new Schema({
  id: { type: 'id' },
  name: { type: 'string', required: true, unique: true },
  slug: { type: 'string', unique: true },
  parentId: { type: 'id', refs: { resource: 'categories' } }
});

// 4. Add resources with configuration
api.addResource('users', userSchema);

api.addResource('posts', postSchema, {
  searchableFields: {
    title: 'title',
    content: 'content', 
    authorName: 'author.username',
    search: '*'  // Virtual field for multi-field search
  }
});

api.addResource('comments', commentSchema, {
  positioning: { 
    field: 'position',
    groupBy: 'postId'  // Separate position sequence per post
  }
});

api.addResource('categories', categorySchema, {
  positioning: { field: 'position' }
});

// 5. Add business logic hooks
// Auto-generate slugs
api.hook('beforeInsert', async (context) => {
  if (context.type === 'posts' && !context.data.slug) {
    context.data.slug = context.data.title
      .toLowerCase()
      .replace(/[^\w\s-]/g, '')
      .replace(/\s+/g, '-');
  }
});

// Hash passwords
api.hook('beforeInsert', async (context) => {
  if (context.type === 'users' && context.data.password) {
    context.data.password = await bcrypt.hash(context.data.password, 10);
  }
});

// Virtual search field
api.hook('modifyQuery', async (context) => {
  if (context.params.filter?.search && context.options.type === 'posts') {
    const search = context.params.filter.search;
    delete context.params.filter.search;
    
    // Search in multiple fields
    context.query.where(
      '(posts.title LIKE ? OR posts.content LIKE ? OR users.username LIKE ?)',
      `%${search}%`, `%${search}%`, `%${search}%`
    );
  }
});

// Auto-approve comments from admin users
api.hook('beforeInsert', async (context) => {
  if (context.type === 'comments' && context.options.user?.role === 'admin') {
    context.data.status = 'approved';
  }
});

// 6. Set up Express app with HTTP plugin
const app = express();

api.use(HTTPPlugin, { 
  app,
  basePath: '/api'
});

// 7. Connect and start
await api.connect();

app.listen(3000, () => {
  console.log('Blog API running on http://localhost:3000');
});

// Example API calls:
// GET    /api/posts?filter[status]=published&sort=-createdAt&page[size]=10
// GET    /api/posts/123?joins=author,category,comments
// POST   /api/posts
// PATCH  /api/posts/123
// DELETE /api/posts/123
// GET    /api/posts?filter[search]=javascript

Summary

This codebase implements a plugin-based REST API system where:

  1. The Api class orchestrates everything
    • Manages resources and schemas
    • Runs hooks in order
    • Delegates storage to plugins
    • Provides a consistent interface
  2. Plugins add specific features
    • Storage: Memory, MySQL (where data lives)
    • Features: Validation, Timestamps, Positioning
    • Security: Authentication, Rate limiting, CORS
    • API: HTTP endpoints, Versioning
    • Monitoring: Logging
  3. Schemas define data structure
    • Field types and validation rules
    • Relationships between resources
    • Default values and constraints
    • Silent fields (never exposed)
  4. Hooks enable extensibility
    • Lifecycle events (before/after operations)
    • Priority ordering
    • Context passing
    • Operation cancellation
  5. Resource proxies provide clean syntax
    • api.resources.users.get(123) instead of api.get('users', 123)
    • Natural, intuitive API
    • Type safety through consistency

The architecture follows these principles:

Remember:

Happy coding! You now understand how this entire codebase works. Start simple, add features as needed, and let the plugins handle the complexity.