Documentation API Cheatsheet

← Back to Home

Hooked API

Hooked API allows you to create API calls that can be extended with hooks and variables. For example you can create a library that connects to a database, and allow users to provide hooks to manipulate the lifecycle of a call.

Documentation

📖 View the official documentation site for the best reading experience.

Or browse the docs here on GitHub:

Your API users will be able to define scopes and hooks to manipulate how the API calls behave.

This library allows you to create APIs that can be extended with plugins and hooks.

The end result of a database layer API could look like this:

import { DbApi } from './DbApi.js'
import { GeneratedOnPlugin } from './GeneratedOnPlugin.js'

const api = new DbApi()

// Add MySql connector with a Plugin
await api.use(GeneratedOnPlugin)

// That's it, "api" is ready to use!

To use it:

// Add scopes (one per table)
await api.addScope('books',
  {
    schema: {
      title: 'string',
      rating: 'number',
    },
  },
  {
    hooks: {
      afterFetch: ({context}) => {
        context.record.titleAndRating = context.record.title + ' ' + context.record.rating
      }
    }
  }
)

await api.addScope('authors',
  {
    schema: {
      fullName: 'string',
      bookId: 'id'
    },
  }
)


const author = await api.scopes.authors.get({ id: 10 });
/* Returns:
  { 
    id: 10,
    fullName: "Umberto Eco",
    generatedOn: 2025-06-28T01:35:40.971Z,
  } 
*/

const book = await api.scopes.books.get({ id: 20 });
/* Returns: 
  { 
    id: 20,
    title: "The Name Of the Rose",
    rating: 10,
    titleAndRating: "The Name Of The Rose 10",
    generatedOn: 2025-06-28T01:35:40.971Z,
  } 
*/

Note that generatedOn was added by the GeneratedOn plugin.

Note: in these examples, db will be mocked as an object that does very little:

// db.js
export const db = {
  fetch: (table, id, params ) => {
    if (table === 'books') return { json: () => ({ title: "The Name Of The Rose", rating: 10, id }) }
    else if (table === 'authors') return { json: () => ({ fullName: "Umberto Eco", id, bookId: 100 }) }
  }
}

This guide is focussed on creating exactly the example API shown above.

First steps: declare a simple function

Here’s the simplest way to create an API with a single method.

Note: Starting from version 2.0.0, the customize(), addScope(), and use() methods are async and must be awaited. This ensures that event handlers can perform critical setup work before the API is ready to use.

import { Api } from 'hooked-api';
import { db } from './db.js'

const api = new Api({
  name: 'library-api',
  version: '1.0.0',
});

api.customize({
  apiMethods: {
    getAuthor: async ({ params }) => { 
      const response = await db.fetch('authors', params.id, {})
      return response.json();
    }
  }
});

Anything defined in apiMethods will be automatically available as an API method:

To use this API, you simply call the getAuthor() method:

const user = await api.getAuthor({ id: 100 });

Of course, you could do this by just plain Javascript:

import { db } from './db.js'
const api = {}

api.getAuthor = async (params) => {
  const response = await db.fetch('authors', params.id, {})
  return response.json();
}

But you would miss out on all of the magic that this library offers (hooks, helpers, variables, plugins, scopes…)

API features: Helpers and variables

You can set helpers function and variables within the API:

import { Api } from 'hooked-api'
import { db } from './db.js'

const api = new Api({
  name: 'library-api',
  version: '1.0.0',
});

api.customize({
  apiMethods: {
    getAuthor: async ({ params, helpers, vars }) => { 
      const response = await db.fetch('authors', params.id, { timeout: vars.timeout })
      const data = response.json();
      data.generatedOn = helpers.makeDate()
      return data
    }
  },
  vars: {
    timeout: 10000
  },
  helpers: {
    makeDate: () => new Date()
  }
});

// Usage is identical
const user = await api.getAuthor({ id: 100 });

As you can see, you can create variables (vars) and helpers (helpers) when you create the API, and you are able to use those in the functions defined in apiMethods.

Direct Access to Variables and Helpers

Besides accessing vars and helpers within method handlers, you can also access them directly:

// Direct access to global vars and helpers
api.vars.timeout = 15000;
const date = api.helpers.makeDate();

// Direct access to scope-specific vars and helpers
api.scopes.users.vars.cacheTimeout = 10000;
const validated = api.scopes.users.helpers.validateUser(userData);

// Important: Scope vars/helpers automatically fall back to global ones
// If 'timeout' is not defined in the users scope, it will return the global value
const timeout = api.scopes.users.vars.timeout; // Returns 15000 (from global)

// But if the scope defines its own value, that takes precedence
await api.addScope('products', {}, {
  vars: { timeout: 30000 } // Override for this scope
});
api.scopes.products.vars.timeout; // Returns 30000 (scope-specific)
api.vars.timeout; // Still returns 15000 (global unchanged)

This direct access is useful for:

More API features: hooks

API methods can be made more configurable by adding hooks. Hooks allow you to intercept and modify behavior at specific points in your method execution. The context object is used to maintain state between hooks, allowing them to share data throughout the method’s lifecycle.

Here is an example of how to improve this library providing hooks:

import { Api } from 'hooked-api';
import { db } from './db.js'

const api = new Api({
  name: 'library-api',
  version: '1.0.0',
});

api.customize({
  apiMethods: {
    getAuthor: async ({ context, params, helpers, vars, runHooks }) => {       

      // Run the before-fetch hooks
      await runHooks('beforeFetch');
 
      // Fetch the data
      const response = await db.fetch('authors', params.id, { timeout: vars.timeout})
      context.record = response.json();

      // Run the after-fetch hooks
      await runHooks('afterFetch');
 
      return context.record
    }
  },
  vars: {
    timeout: 10000
  },
  helpers: {
    makeDate: () => new Date()
  },
  hooks: {
    afterFetch: ({context, helpers}) => {
      context.record.generatedOn = helpers.makeDate()
    }
  }
});

Here, the data manipulation was delegated to a hook, which added the random field to the returned data.

You don’t know how many hooks will be run when you run runHooks(). However, runHooks() will return true if all hooks ran, and false if the running chain was interrupted.

A hook can interrupt the running chain by returning false.

Advanced Hook Definition: Object Format

While the examples above show hooks as simple functions, you can also define hooks using an object format that provides more control over the hook’s metadata and placement:

api.customize({
  hooks: {
    // Simple function format (what we've seen so far)
    simpleHook: ({ context }) => {
      console.log('Simple hook');
    },
    
    // Object format with full control
    advancedHook: {
      handler: async ({ context, params }) => {
        console.log('Advanced hook with custom name');
      },
      functionName: 'myCustomHookName',  // Optional: custom name for debugging
      beforePlugin: 'SomeOtherPlugin'     // Optional: placement option
    }
  }
});

The object format accepts these properties:

This same object format works in addScope:

api.addScope('users', {}, {
  hooks: {
    beforeSave: {
      handler: async ({ context, helpers }) => {
        // Validate email format
        if (!helpers.validateEmail(context.record.email)) {
          throw new Error('Invalid email format');
        }
      },
      functionName: 'validateUserEmail',
      beforeFunction: 'sanitizeData'  // Run before another hook
    },
    
    afterSave: {
      handler: ({ context, vars }) => {
        // Send notification
        vars.eventBus.emit('user:saved', context.record);
      },
      functionName: 'notifyUserSaved',
      afterPlugin: 'DatabasePlugin'  // Run after all DatabasePlugin hooks
    }
  }
});

This object format is particularly useful when:

  1. You need to specify hook placement options
  2. You want to give your hooks meaningful names for debugging
  3. You’re building complex hook chains that reference each other
  4. You need to ensure your hooks run in a specific order relative to other hooks

Note: When using customize() or addScope(), the hooks are automatically associated with a special plugin name (api-custom:${apiName} or scope-custom:${scopeName}), which allows them to be referenced by placement options.

Scopes: Organizing Different Types of Data

In many cases it’s crucial to have scopes; in this case, we will map a scope to a database table. Note that the property scopeMethods is used instead of apiMethods:

import { Api } from 'hooked-api'
import { db } from './db.js'

const api = new Api({
  name: 'library-api',
  version: '1.0.0',
});

api.customize({
  // Note that we are now defining scope methods...
  scopeMethods: {
    get: async ({ context, scopeOptions, params, helpers, vars, scopeName, runHooks }) => { 

      // Run the before-fetch hooks
      await runHooks('beforeFetch');
 
      // Fetch the data. The table used will depend on the scope name
      const response = await db.fetch(scopeName, params.id, { timeout: vars.timeout})
      context.record = response.json();

      // Run the after-fetch hooks
      await runHooks('afterFetch');
 
      return context.record
    }
  },
  vars: {
    timeout: 10000
  },
  helpers: {
    makeDate: () => new Date()
  },
  hooks: {
    // No matter what table is fetched, every record will have this timestamp
    afterFetch: ({context, helpers}) => {
      context.record.generatedOn = helpers.makeDate()
    }
  }
});

Since we defined scopeMethods instead of apiMethods, those methods will only be available to defined scopes. To define a scope:

await api.addScope('books',
  {
    schema: {
      title: 'string',
      rating: 'number',
    },
  },
  {
    hooks: {
      afterFetch: ({context}) => {
        context.record.titleAndRating = context.record.title + ' ' + context.record.rating
      }
    }
  }
)

await api.addScope('authors',
  {
    schema: {
      fullName: 'string',
      bookId: 'id'
    },
  }
)

The first parameter is the scope’s name (books or authors); the second parameter is the scope’s options. In this case, we defined schema (which at this point is not used in the current implementation). Both scopes will return records with generatedOn set to the current date, but only books will have titleAndRating since the hook is limited to the books scope.

Attempting to call a scope directly throws a helpful error:

api.scopes.users() // Throws: "Direct scope call not supported. Use api.scopes.users.methodName()"

Advanced Scope Features

Scopes support several advanced features that make them powerful for organizing your API:

Scope-Specific Customization

Each scope can have its own hooks, vars, and helpers that are merged with global ones:

// Global hooks and vars
api.customize({
  vars: { timeout: 5000 },
  helpers: { formatDate: (d) => d.toISOString() },
  hooks: {
    beforeSave: ({ context }) => {
      context.timestamp = new Date();
    }
  }
});

// Scope-specific customization
api.addScope('users', 
  { schema: { name: 'string', email: 'string' } },
  {
    // These vars override global vars of the same name
    vars: { timeout: 10000 },  // Users need longer timeout
    
    // These helpers are added to global helpers
    helpers: { 
      validateEmail: (email) => email.includes('@') 
    },
    
    // These hooks only run for user operations
    hooks: {
      beforeSave: ({ context, helpers }) => {
        if (!helpers.validateEmail(context.record.email)) {
          throw new Error('Invalid email');
        }
      }
    }
  }
);

// Scope methods can override global scope methods
api.addScope('products', 
  { schema: { name: 'string', price: 'number' } },
  {
    scopeMethods: {
      // Override the global 'get' method just for products
      get: async ({ params, scope }) => {
        const product = await globalGet(params);
        product.formattedPrice = `$${product.price}`;
        return product;
      }
    }
  }
);

Direct Scope Access in Hooks

When hooks run in a scope context, they receive the current scope object, allowing direct method calls:

api.customize({
  scopeMethods: {
    validate: async ({ params, scopeOptions }) => {
      // Validation logic based on scope's schema
      return validateAgainstSchema(params, scopeOptions.schema);
    },
    save: async ({ params, scope, runHooks }) => {
      // Can call other methods on the same scope directly
      await scope.validate(params);
      
      if (await runHooks('beforeSave')) {
        return await database.save(params);
      }
    }
  },
  hooks: {
    beforeSave: async ({ context, scope, scopeName }) => {
      // The scope parameter lets you call methods on the current scope
      const isValid = await scope.validate(context.record);
      
      console.log(`Validating record for ${scopeName}:`, isValid);
      return isValid; // Return false to cancel the save
    }
  }
});

Scope Aliases

You can create custom aliases for the scope property to make your API more domain-specific:


// Create an alias "table" that points to "scopes"
dbApi.setScopeAlias('tables', 'addTable');

// api.addTable('books', ...)
// api.tables.books.get(...)

The first parameter is the alias for api.scopes, the second parameter is the alias for api.addScope. These aliases make the code more expressive and easy to understand.

Plugins

Plugins are what make this library actually useful and demonstrate its true extensibility. They allow you to bundle reusable functionalities (API methods, scope methods, hooks, vars, helpers, and even new scopes) into self-contained modules that can be easily added to any Api instance. This promotes code reuse, separation of concerns, and simplifies the development of complex API behaviors.

Imagine you want to add a logging mechanism, authentication features, or a specialized data transformation pipeline that can be applied across different API instances without rewriting the code. That’s where plugins shine.

This is the database code seen above, turned into a plugin.

// DatabasePlugin.js
import { db } from './db.js'
export const DatabasePlugin = {
  name: 'DatabasePlugin',
  
  dependencies: [], // This plugin stands alone
  
  install: ({ setScopeAlias, addScopeMethod, addScope, vars, helpers, pluginName, apiOptions }) => {
  
    addScopeMethod('get', async ({ context, scopeOptions, params, helpers, scopeName, runHooks }) => {

      // Run the before-fetch hooks
      await runHooks('beforeFetch');
 
      // Fetch the data. The table used will depend on the scope name
      const response = await db.fetch(scopeName, params.id, { timeout: vars.timeout})
      context.record = response.json();

      // Run the after-fetch hooks
      await runHooks('afterFetch');


      return context.record
    });

    setScopeAlias('tables', 'addTable');

    // Set vars and helpers directly
    vars.timeout = 10000
  },
};

export default DatabasePlugin;

We should also add a GeneratedOnPlugin, like this:

// GeneratedOnPlugin.js
export const GeneratedOnPlugin = {
  name: 'GeneratedOnPlugin',
  
  dependencies: ['DatabasePlugin'],
  
  install: ({ addScopeMethod, addHook, vars, helpers, pluginName, apiOptions }) => {

    // The helper used by the hook
    helpers.makeDate = () => new Date()

    // The hook that will adds the generatedOn to all records
    addHook('afterFetch', 'addGeneratedOn', {}, ({context, helpers}) => {
      context.record.generatedOn = helpers.makeDate()
    })
  },
}

This plugin will be available to library users who want to add the generatedOn field to their records. At this point, you can just make a new Api object, and add the two plugins to it:

import { DatabasePlugin } from './DatabasePlugin.js'
import { GeneratedOnPlugin } from './GeneratedOnPlugin.js'


const api = new Api({
  name: 'library-api',
  version: '1.0.0'
})

await api.use(DatabasePlugin)
await api.use(GeneratedOnPlugin)

// api.addScope('books', ...)
// api.addScope('authors', ...)

Creating Hookable Plugin Operations

Plugins can create their own hookable operations using the runHooks function. This is useful when your plugin performs complex operations that other plugins might want to extend or intercept.

// HttpServerPlugin.js
export const HttpServerPlugin = {
  name: 'HttpServerPlugin',
  
  install: ({ api, runHooks, log }) => {
    // Create HTTP namespace
    api.http = {
      async handleRequest(req, res) {
        // Create context for this operation
        const context = {
          req,
          res,
          handled: false,
          auth: { userId: null, claims: null }
        };
        
        // Run hooks - other plugins can intercept or modify the request
        context.url = req.url;
        context.method = req.method;
        context.headers = req.headers;
        const shouldContinue = await runHooks('http:request', context);
        
        // Check if a hook handled the request
        if (!shouldContinue || context.handled) {
          return; // Request was intercepted
        }
        
        // Continue with normal processing
        log.info(`Processing ${req.method} ${req.url}`);
        // ... handle the request
      }
    };
  }
};

// AuthPlugin can hook into HTTP requests
export const AuthPlugin = {
  name: 'AuthPlugin',
  
  install: ({ addHook }) => {
    addHook('http:request', 'authenticate', {}, async ({ context }) => {
      const { url, headers } = context;
      
      // Intercept auth endpoints
      if (url.startsWith('/auth/')) {
        context.res.writeHead(200, { 'Content-Type': 'application/json' });
        context.res.end(JSON.stringify({ status: 'auth handled' }));
        context.handled = true;
        return false; // Stop processing
      }
      
      // Add auth info to context for other requests
      if (headers.authorization) {
        context.auth.userId = 'user-123';
        context.auth.claims = { role: 'admin' };
      }
      
      return true; // Continue processing
    });
  }
};

// Usage
const api = new Api({ name: 'web-api', version: '1.0.0' });
await api.use(HttpServerPlugin);
await api.use(AuthPlugin);

// Now when handleRequest is called, AuthPlugin's hook will run
await api.http.handleRequest(req, res);

This pattern allows plugins to create extensible operations that other plugins can participate in, similar to how method lifecycle hooks work.

Making a pre-hooked Api class

Most of the time (in fact, probably all of the time) you will want to distribute a ready-to-go class with a base, initial plugin pre-used in it. Here is what you do:

// DbApi.js
import { DatabasePlugin } from './DatabasePlugin.js'
import { Api } from 'hooked-api'; // Adjust the path to your Api class

class DbApi extends Api {

  constructor(apiOptions = {}) {
    
    // This will add the API to the registry
    super(apiOptions);

    // Note: Plugins should be added after construction
    // since use() is now async
  }
  
  async initialize() {
    // Use the core plugin by default
    await this.use(DatabasePlugin);
    return this;
  }
}

export default DbApi;

To use it:

import { DbApi } from './DbApi.js'
import { GeneratedOnPlugin } from './GeneratedOnPlugin.js'


const api = new DbApi({
  name: 'library-api',
  version: '1.0.0'
})

// Initialize the API with its default plugins
await api.initialize();

// You can add "GeneratedOnPlugin" if you like
await api.use(GeneratedOnPlugin)

// Then add books as you wish
// await api.addScope('books', ...)
// await api.addScope('authors', ...)

Hook Placement Options

When adding hooks, you can control their execution order using placement options. This is useful when you need hooks to run in a specific sequence, regardless of when plugins are installed.

Consider this scenario: You want to add console messages to DbApi, but you need to write the message BEFORE any modifications:

// WriteMessagePlugin.js - A plugin that logs data access
const WriteMessagePlugin = {
  name: 'WriteMessagePlugin',
  install: ({ addHook }) => {
    // Run BEFORE any hooks from GeneratedOnPlugin
    addHook('afterFetch', 'logFetch', {
      beforePlugin: 'GeneratedOnPlugin'
    }, ({ context, log }) => {
      log.debug('Original fetched record:', context.record);
      // This logs the record WITHOUT generatedOn
    });
  }
};

// DbApi.js - Updated to include WriteMessagePlugin
import { DatabasePlugin } from './DatabasePlugin.js'
import { WriteMessagePlugin } from './WriteMessagePlugin.js'
import { Api } from './index.js';

class DbApi extends Api {
  constructor(apiOptions = {}) {
    super(apiOptions);
    
    // Note: Plugins should be added after construction
    // since use() is now async
  }
  
  async initialize() {
    // Use the core plugins
    await this.use(DatabasePlugin);
    await this.use(WriteMessagePlugin);  // WriteMessage added to base API
    return this;
  }
}

// Usage
const api = new DbApi({ name: 'library-api', version: '1.0.0' });
await api.initialize();  // Initialize with base plugins
await api.use(GeneratedOnPlugin);  // User adds this plugin

// Even though WriteMessagePlugin was installed BEFORE GeneratedOnPlugin,
// the 'beforePlugin' option ensures it logs the original record

Available Placement Options

Only one placement option can be used per hook.

Example: Using beforeFunction/afterFunction

The beforeFunction and afterFunction options let you target specific hook functions by name, which is useful when you need fine-grained control:

// ValidationPlugin with multiple hooks
const ValidationPlugin = {
  name: 'ValidationPlugin',
  install: ({ addHook }) => {
    // First validation - check required fields
    addHook('beforeSave', 'validateRequired', {}, ({ context }) => {
      if (!context.record.title) {
        throw new Error('Title is required');
      }
    });
    
    // Second validation - check data types
    addHook('beforeSave', 'validateTypes', {}, ({ context }) => {
      if (typeof context.record.rating !== 'number') {
        throw new Error('Rating must be a number');
      }
    });
  }
};

// SanitizationPlugin needs to run between the two validations
const SanitizationPlugin = {
  name: 'SanitizationPlugin',
  install: ({ addHook }) => {
    addHook('beforeSave', 'sanitize', {
      afterFunction: 'validateRequired',  // Run AFTER required field check
      // This ensures we sanitize before type validation
    }, ({ context }) => {
      // Convert rating to number if it's a string
      if (typeof context.record.rating === 'string') {
        context.record.rating = parseInt(context.record.rating, 10);
      }
    });
  }
};

// Usage
await api.use(ValidationPlugin);
await api.use(SanitizationPlugin);

// Hook execution order for 'beforeSave':
// 1. validateRequired (checks if title exists)
// 2. sanitize (converts rating "10" to 10)
// 3. validateTypes (now passes because rating is a number)

This example shows why beforeFunction/afterFunction are useful: they let you insert hooks at specific points within a plugin’s hook chain, not just before or after the entire plugin.

Please note that plugin order still matters in the sense that hook ordering is established at adding time. This means that a plugin can only place hooks before others, but only relative to the plugins already installed. This is how the API is meant to work.

Hook Execution Control

Hooks can return false to stop the execution of remaining hooks in the chain.

Logging

Hooked API includes a comprehensive logging system that helps you debug API behavior and monitor performance. The logging system is integrated throughout the library and available in all handlers.

Configuring Logging

When creating an API instance, you can configure logging through the options. The defaults are shown below:

import { Api, LogLevel } from './index.js';

const api = new Api({
  name: 'my-api',
  version: '1.0.0',
  logging: {
    level: 'info',        // 'error', 'warn', 'info', 'debug', 'trace'
    format: 'pretty',     // 'pretty' or 'json'
    timestamp: true,      // Include timestamps in logs
    colors: true,         // Use ANSI colors (only with 'pretty' format)
    logger: console       // Custom logger object (must have log/error/warn methods)
  }
});

// Using numeric log level
const api2 = new Api({
  name: 'my-api-2',
  version: '1.0.0',
  logging: {
    level: LogLevel.DEBUG,  // Using the exported enum
    logger: console
  }
});

// Using string log level
const api3 = new Api({
  name: 'my-api-3',
  version: '1.0.0',
  logging: {
    level: 'debug',  // Case-insensitive string
    logger: console
  }
});

Log Levels

The library supports five log levels, from least to most verbose:

Level Numeric Value String Value Description
ERROR 0 ‘error’ Critical errors only
WARN 1 ‘warn’ Warnings and errors
INFO 2 ‘info’ General information (default)
DEBUG 3 ‘debug’ Detailed debugging information
TRACE 4 ‘trace’ Very detailed execution traces

You can set the log level using either:

import { LogLevel } from './index.js';

// All these are equivalent ways to set DEBUG level:
logging: { level: LogLevel.DEBUG, logger: console }
logging: { level: 3, logger: console }
logging: { level: 'debug', logger: console }
logging: { level: 'DEBUG', logger: console }

Invalid log levels will default to INFO (2), except numbers outside 0-4 which throw a ConfigurationError.

Using the Logger in Handlers

Every handler receives a log object that provides logging methods:

// In your DbApi implementation
class DbApi extends Api {
  constructor() {
    super({ name: 'db-api', version: '1.0.0' });
    
    this.customize({
      apiMethods: {
        healthCheck: async ({ log }) => {
          log.trace('Health check started');
          
          try {
            // Check database connection
            const result = await db.ping();
            log.debug('Database ping successful', result);
            log.info('Health check passed');
            return { status: 'healthy' };
          } catch (error) {
            log.error('Health check failed', error);
            throw error;
          }
        }
      }
    });
  }
}

Logger Methods

The log object provides these methods:

// Inside any handler, hook, or plugin
async function myHandler({ log, params }) {
  log('Simple info message');           // Shorthand for log.info()
  log.error('Error occurred', error);   // Log errors with details
  log.warn('Deprecation warning');      // Log warnings
  log.info('Processing request', params); // Log general information
  log.debug('Detailed state', state);   // Log debugging details
  log.trace('Method entry/exit');       // Log execution traces
}

Logging in Plugins

Plugins can also use logging during installation and in their hooks:

const PerformancePlugin = {
  name: 'PerformancePlugin',
  install: ({ addHook, log }) => {
    log.info('Installing PerformancePlugin');
    
    addHook('beforeMethod', 'startTimer', {}, ({ context, log }) => {
      context.startTime = Date.now();
      log.trace('Timer started');
    });
    
    addHook('afterMethod', 'logDuration', {}, ({ context, log }) => {
      const duration = Date.now() - context.startTime;
      log.debug(`Method completed in ${duration}ms`);
      
      if (duration > 1000) {
        log.warn(`Slow method detected: ${duration}ms`);
      }
    });
  }
};

Scope-Specific Logging

Scopes can have their own log levels:

api.addScope('users', {
  logging: { level: 'debug' },  // More verbose for this scope
  schema: { /* ... */ }
});

Log Output Examples

Pretty format (default):

2025-06-28T12:00:00.000Z [INFO] [my-api:getData] Data retrieved successfully { count: 42 }
2025-06-28T12:00:01.000Z [ERROR] [my-api:users.create] Validation failed { field: 'email' }

JSON format:

{"level":"INFO","api":"my-api","context":"getData","message":"Data retrieved successfully","data":{"count":42},"timestamp":"2025-06-28T12:00:00.000Z"}

What Gets Logged Automatically

At different log levels, the library automatically logs:

INFO level:

DEBUG level:

TRACE level:

Performance Monitoring

The library automatically tracks execution time for all operations when using DEBUG or TRACE log levels:

// Enable timing logs
const api = new Api({
  name: 'my-api',
  version: '1.0.0',
  logging: { level: 'debug', logger: console }
});

// Example output when calling a method:
// [DEBUG] [my-api] API method 'getData' called { params: { id: 123 } }
// [DEBUG] [my-api] API method 'getData' completed { duration: '45ms' }

// Hook execution timing:
// [DEBUG] [my-api] Running hook 'beforeFetch' { handlerCount: 3 }
// [DEBUG] [my-api] Hook 'beforeFetch' completed { handlersRun: 3, duration: '12ms' }

// Plugin installation timing:
// [INFO] [my-api] Installing plugin 'CachePlugin' { options: { ttl: 300 } }
// [INFO] [my-api] Plugin 'CachePlugin' installed successfully { duration: '8ms' }

This timing information helps you:

For production environments, consider using a custom logger to send timing metrics to your monitoring system:

const metricsLogger = {
  log: (message) => {
    // Parse timing information and send to metrics service
    if (message.includes('duration:')) {
      const match = message.match(/duration: '(\d+)ms'/);
      if (match) {
        metricsService.recordTiming(match[1]);
      }
    }
  },
  error: console.error,
  warn: console.warn
};

const api = new Api({
  name: 'my-api',
  version: '1.0.0',
  logging: { level: 'info', logger: metricsLogger }
});

Custom Logger

You can provide a custom logger implementation:

const customLogger = {
  log: (message, data) => {
    const logEntry = data ? `${message} ${JSON.stringify(data)}` : message;
    fs.appendFileSync('app.log', logEntry + '\n');
  },
  error: (message, data) => {
    const logEntry = data ? `${message} ${JSON.stringify(data)}` : message;
    fs.appendFileSync('error.log', logEntry + '\n');
  },
  warn: (message, data) => {
    const logEntry = data ? `${message} ${JSON.stringify(data)}` : message;
    fs.appendFileSync('warn.log', logEntry + '\n');
  }
};

const api = new Api({
  name: 'my-api',
  version: '1.0.0',
  logging: { logger: customLogger }
});

Alternatively, you can use the spread operator to handle all arguments:

const customLogger = {
  error: (...args) => myLoggingService.log('error', ...args),
  warn: (...args) => myLoggingService.log('warn', ...args),
  info: (...args) => myLoggingService.log('info', ...args),
  debug: (...args) => myLoggingService.log('debug', ...args),
  trace: (...args) => myLoggingService.log('trace', ...args),
};

Best Practices

  1. Production: Use ERROR or WARN level to minimize overhead
  2. Development: Use INFO or DEBUG for helpful insights
  3. Debugging: Use TRACE to see complete execution flow
  4. Custom Logger: You can provide any logger that implements the console interface
  5. Sensitive Data: Be careful not to log sensitive information like passwords or API keys

Security

Hooked API includes several security features to protect against common vulnerabilities and ensure safe operation.

Prototype Pollution Protection

The library actively prevents prototype pollution attacks by blocking access to dangerous properties:

// These property names are blocked in all contexts
const DANGEROUS_PROPS = ['__proto__', 'constructor', 'prototype'];

// Attempting to use these will throw an error
await api.addScope('__proto__', {});  // Throws ValidationError
await api.customize({
  apiMethods: {
    constructor: async () => {}  // Throws ValidationError
  }
});

Input Validation

All method and scope names must be valid JavaScript identifiers to prevent injection attacks:

// Valid names (matching /^[a-zA-Z_$][a-zA-Z0-9_$]*$/)
await api.addScope('users', {});         // ✓ Valid
await api.addScope('_private', {});      // ✓ Valid
await api.addScope('$special', {});      // ✓ Valid

// Invalid names throw ValidationError with helpful messages
await api.addScope('user-list', {});     // ✗ Invalid: contains '-'
await api.addScope('123users', {});      // ✗ Invalid: starts with number
await api.addScope('user.list', {});     // ✗ Invalid: contains '.'

When validation fails, the library provides detailed error messages:

Reserved Names and Conflict Prevention

The library prevents overwriting critical API properties:

// Reserved plugin names
await api.use({
  name: 'api',      // Throws PluginError: 'api' is reserved
  install: () => {}
});

await api.use({
  name: 'scopes',   // Throws PluginError: 'scopes' is reserved
  install: () => {}
});

// Property conflict detection
api.customize({
  apiMethods: {
    use: async () => {}  // Throws MethodError: 'use' already exists
  }
});

Duplicate Detection

The library prevents duplicate registrations across multiple contexts:

// Duplicate API versions
new Api({ name: 'my-api', version: '1.0.0' });
new Api({ name: 'my-api', version: '1.0.0' }); // Throws ConfigurationError

// Duplicate scope names
await api.addScope('users', {});
await api.addScope('users', {});  // Throws ScopeError

// Duplicate plugin names
await api.use(MyPlugin);
await api.use(MyPlugin);  // Throws PluginError

Frozen Options

All options objects are frozen when passed to handlers, preventing accidental or malicious modifications:

await api.customize({
  apiMethods: {
    test: async ({ params, options }) => {
      // options is frozen - modifications will fail
      options.name = 'hacked';        // TypeError: Cannot assign to read only property
      options.newProp = 'value';       // TypeError: Cannot add property
      delete options.version;          // TypeError: Cannot delete property
    }
  }
});

Symbol and Numeric Property Filtering

The library filters certain property types when accessing scopes for security:

// Symbols are filtered to prevent symbol-based attacks
const sym = Symbol('hidden');
api.scopes[sym] = 'malicious';  // Silently ignored
console.log(api.scopes[sym]);   // undefined

// Numeric string properties are also filtered
api.scopes['123'] = 'malicious';  // Silently ignored
console.log(api.scopes['123']);   // undefined

// This prevents array-like access patterns
api.scopes.users[0]  // undefined (security feature)

// Use proper API methods instead
api.scopes.users.get({ id: 123 })  // Correct approach

Best Practices for API Developers

When building APIs with hooked-api, follow these security best practices:

  1. Validate all parameters in your API methods - Never trust input from API consumers:
    apiMethods: {
      updateUser: async ({ params }) => {
        // Validate before using
        if (!params.id || typeof params.id !== 'number') {
          throw new Error('Invalid user ID');
        }
        if (params.email && !isValidEmail(params.email)) {
          throw new Error('Invalid email format');
        }
        // Now safe to use params
      }
    }
    
  2. Carefully review third-party plugins - Plugins have full access to your API:
    • Check what hooks they add
    • Review what data they access
    • Ensure they don’t expose sensitive operations
    • Consider the plugin’s source and maintenance status
  3. Sanitize data in hooks - Hooks can modify shared context:
    hooks: {
      beforeSave: ({ context }) => {
        // Sanitize any HTML/scripts from user content
        context.record.description = sanitizeHtml(context.record.description);
        // Remove any unexpected fields
        delete context.record.internalField;
      }
    }
    
  4. Implement authentication and authorization - The library doesn’t provide this:
    apiMethods: {
      deleteUser: async ({ params, vars }) => {
        // Check authentication
        if (!vars.currentUser) {
          throw new Error('Authentication required');
        }
        // Check authorization
        if (!vars.currentUser.isAdmin) {
          throw new Error('Admin access required');
        }
        // Proceed with deletion
      }
    }
    
  5. Use logging for security monitoring - Track suspicious activities:
    apiMethods: {
      login: async ({ params, log }) => {
        const result = await authenticate(params);
        if (!result.success) {
          log.warn('Failed login attempt', { 
            username: params.username,
            ip: params.clientIp,
            timestamp: new Date()
          });
        }
        return result;
      }
    }
    
  6. Handle errors carefully - Don’t expose internal details:
    apiMethods: {
      getData: async ({ params }) => {
        try {
          return await internalDatabaseQuery(params);
        } catch (error) {
          // Log the full error internally
          log.error('Database query failed', error);
          // Return sanitized error to API consumer
          throw new Error('Unable to retrieve data');
        }
      }
    }
    
  7. Protect sensitive operations in helpers - Don’t expose dangerous functions:
    helpers: {
      // DON'T expose direct database access
      // db: database,  // ❌ Bad
         
      // DO create safe, limited helpers
      findUserByEmail: async (email) => {  // ✓ Good
        // Only returns public user data
        const user = await database.findUser({ email });
        return { id: user.id, name: user.name };
      }
    }
    
  8. Consider rate limiting - Implement in your API methods:
    apiMethods: {
      search: async ({ params, vars }) => {
        // Simple rate limit check
        const key = `search:${vars.clientId}`;
        const count = await rateLimiter.increment(key);
        if (count > 100) {
          throw new Error('Rate limit exceeded');
        }
        return await performSearch(params);
      }
    }
    

Event System

In addition to hooks (which intercept and modify behavior of methods), Hooked API provides an event system for lifecycle notifications to the API itself. Events are simpler than hooks - they notify about system changes but cannot modify behavior or stop execution.

Important: Events are now properly awaited during API setup. When you call await api.addScope(), the method will wait for all scope:added event handlers to complete before returning. This ensures that critical setup work (like schema initialization) is completed before the scope is usable.

Events vs Hooks

Aspect Hooks Events
Purpose Modify behavior during execution Notify about system changes
Can stop execution Yes (return false) No
Error handling Errors propagate and stop execution Errors are logged but isolated
Use cases Validation, transformation, cancellation Logging, synchronization, monitoring
Context Full method context with params Simpler event-specific data

Available Events

The following system hooks are emitted:

Cross-Plugin Communication

React to other plugins being installed:

const IntegrationPlugin = {
  name: 'integration-plugin',
  
  install({ on, vars }) {
    on('plugin:installed', 'integrateWithPlugin', ({ eventData }) => {
      // React to specific plugins
      if (eventData.pluginName === 'auth-plugin') {
        console.log('Auth plugin detected, enabling authentication features');
        vars.authEnabled = true;
      }
    });
  }
};

Monitoring Plugin

Track all API changes for auditing:

const MonitoringPlugin = {
  name: 'monitoring-plugin',
  
  install({ on }) {
    const changes = [];
    
    // Track all system changes
    on('scope:added', 'trackScope', ({ eventData }) => {
      changes.push({ type: 'scope', name: eventData.scopeName, time: Date.now() });
    });
    
    on('method:api:added', 'trackApiMethod', ({ eventData }) => {
      changes.push({ type: 'api-method', name: eventData.methodName, time: Date.now() });
    });
    
    on('method:scope:added', 'trackScopeMethod', ({ eventData }) => {
      changes.push({ type: 'scope-method', name: eventData.methodName, time: Date.now() });
    });
    
    // Expose the audit log
    api.getAuditLog = () => changes;
  }
};

Error Handling

Event handler errors are logged and re-thrown. This ensures that critical setup errors don’t go unnoticed during API initialization:

const SafePlugin = {
  name: 'safe-plugin',
  
  install({ on }) {
    on('scope:added', 'mightFail', ({ eventData }) => {
      if (eventData.scopeName === 'special') {
        throw new Error('This error is logged but isolated');
      }
      console.log('Normal processing continues');
    });
  }
};

// Usage - the error will now propagate
await api.use(SafePlugin);
try {
  await api.addScope('special');  // Error is thrown and must be handled
} catch (error) {
  console.error('Failed to add scope:', error);
}
await api.addScope('normal');   // Only runs if previous error was caught

Best Practices

  1. Use events for notifications, not control flow - Events cannot stop or modify operations
  2. Keep event handlers lightweight - They run synchronously and can impact performance
  3. Handle errors gracefully - Event errors are logged and re-thrown, so ensure your event handlers don’t throw unless it’s critical
  4. Don’t modify critical state - Use hooks for state modifications that affect behavior
  5. Consider event ordering - Listeners execute in registration order

API Registry and Versioning

Hooked API includes a global registry that tracks all API instances by name and version. This enables powerful versioning capabilities and allows different parts of your application to access specific API versions.

Creating Versioned APIs

Every API instance must have a unique name and version combination:

import { Api } from './index.js';

// Create version 1.0.0
const apiV1 = new Api({
  name: 'user-api',
  version: '1.0.0'
});

// Create version 2.0.0 with breaking changes
const apiV2 = new Api({
  name: 'user-api',
  version: '2.0.0'
});

// Attempting to create a duplicate throws an error
const duplicate = new Api({
  name: 'user-api',
  version: '1.0.0'  // Throws ConfigurationError
});

Accessing API Instances

The Api.registry provides methods to retrieve and query registered APIs:

// Get the latest version (highest semver)
const latest = Api.registry.get('user-api');
const alsoLatest = Api.registry.get('user-api', 'latest');

// Get a specific version
const v1 = Api.registry.get('user-api', '1.0.0');
const v2 = Api.registry.get('user-api', '2.0.0');

// Use semver ranges
const compatible = Api.registry.get('user-api', '^1.0.0');  // Gets 1.x.x
const minor = Api.registry.get('user-api', '~1.0.0');       // Gets 1.0.x
const anyV2 = Api.registry.get('user-api', '2.x');          // Gets 2.x.x

// Returns null for non-existent versions
const missing = Api.registry.get('user-api', '3.0.0');      // null
const invalid = Api.registry.get('user-api', 'invalid');    // null

Registry Methods

// List all registered APIs and their versions
const registry = Api.registry.list();
// Returns: { 'user-api': ['2.0.0', '1.0.0'], 'product-api': ['1.0.0'] }

// Check if an API exists
Api.registry.has('user-api');           // true
Api.registry.has('user-api', '1.0.0');  // true
Api.registry.has('user-api', '3.0.0');  // false

// Get all versions of a specific API
const versions = Api.registry.versions('user-api');
// Returns: ['2.0.0', '1.0.0'] (sorted by semver, highest first)

Version Migration Example

The registry enables smooth version migrations:

// Old code using v1
function oldFeature() {
  const api = Api.registry.get('user-api', '^1.0.0');
  return api.scopes.users.list();
}

// New code using v2
function newFeature() {
  const api = Api.registry.get('user-api', '^2.0.0');
  return api.scopes.users.query();  // v2 uses 'query' instead of 'list'
}

// Adapter for backward compatibility
function adaptedFeature(version = 'latest') {
  const api = Api.registry.get('user-api', version);
  
  if (api.options.version.startsWith('1.')) {
    return api.scopes.users.list();
  } else {
    return api.scopes.users.query();
  }
}

Best Practices

  1. Semantic Versioning: Follow semver conventions (major.minor.patch)
  2. Version Documentation: Document breaking changes between major versions
  3. Gradual Migration: Use the registry to run multiple versions during transitions
  4. Version Detection: Check api.options.version when behavior differs between versions
  5. Testing: Use resetGlobalRegistryForTesting() between tests to avoid conflicts
import { resetGlobalRegistryForTesting } from './index.js';

// In your test setup
beforeEach(() => {
  resetGlobalRegistryForTesting();
});