JSON REST API - API Reference
Complete API reference for the JSON REST API library.
Table of Contents
- Api Class
- Schema Class
- QueryBuilder Class
- Resource Proxy API
- Plugin Interface
- Hook Reference
- Error Classes
- Type Definitions
Api Class
The main class for creating and managing REST APIs.
Constructor
new Api(options?: ApiOptions)
Options
Option | Type | Default | Description |
---|---|---|---|
idProperty |
string | 'id' |
Name of the ID field |
name |
string | null |
API name for registry |
version |
string | null |
API version (semver) |
artificialDelay |
number | 0 |
Delay in ms for testing |
createApi() Function
A convenience function that creates and configures an API instance with common plugins automatically:
import { createApi } from 'json-rest-api';
const api = createApi({
name: 'myapp',
version: '1.0.0',
storage: 'memory', // or 'mysql'
validation: true, // Default: true
http: { app }, // Auto-mounts HTTP routes
// Plugin-specific options
mysql: { connection: { /* ... */ } },
timestamps: true,
versioning: { /* ... */ },
positioning: { /* ... */ }
});
What createApi() does:
- Creates a new
Api
instance with provided options - Automatically adds plugins based on configuration:
ValidationPlugin
ifvalidation !== false
MemoryPlugin
ifstorage === 'memory'
MySQLPlugin
ifstorage === 'mysql'
HTTPPlugin
ifhttp
options provided- Other plugins based on their respective options
- Ensures proper plugin ordering automatically
When to use createApi() vs manual setup:
Use createApi() when:
- You want standard functionality quickly
- You don’t need custom plugin ordering
- You’re using common plugin combinations
Use manual Api() + use() when:
- You need precise control over plugin order
- You’re using custom plugins
- You need conditional plugin loading
- You want to configure plugins differently
// Manual setup example
const api = new Api({ name: 'myapp' });
api.use(MySQLPlugin, { /* custom config */ });
api.use(CustomAuthPlugin);
api.use(ValidationPlugin);
api.use(HTTPPlugin);
Static Methods
Api.get(name, version)
Get an API instance from the global registry.
const api = Api.get('myapp', '1.0.0');
const latest = Api.get('myapp'); // Gets latest version
Api.registry
Access the global API registry.
// Check if API exists
if (Api.registry.has('myapp', '1.0.0')) { }
// Get all versions
const versions = Api.registry.versions('myapp'); // ['1.0.0', '1.1.0']
// List all APIs
const all = Api.registry.list(); // { myapp: ['1.0.0'], otherapp: ['2.0.0'] }
Instance Methods
use(plugin, options?)
Add a plugin to the API.
api.use(MySQLPlugin, { connection: dbConfig });
addResource(type, schema, hooksOrOptions?)
Register a resource type.
// Basic usage
api.addResource('users', userSchema, {
afterInsert: async (context) => {
// Resource-specific hook
}
});
// With searchable field mappings
api.addResource('posts', postSchema, {
searchableFields: {
author: 'authorId.name', // Filter by author name via join
category: 'categoryId.title', // Filter by category title
search: '*' // Virtual field - requires custom handler
},
hooks: {
afterInsert: async (context) => {
// Resource-specific hook
}
}
});
// Virtual search fields (marked with '*') require a handler
api.hook('modifyQuery', async (context) => {
if (context.params.filter?.search && context.options.type === 'posts') {
const value = context.params.filter.search;
// Custom search logic
context.query.where(
'(posts.title LIKE ? OR posts.content LIKE ? OR posts.tags LIKE ?)',
`%${value}%`, `%${value}%`, `%${value}%`
);
// Remove from filter to prevent column lookup
delete context.params.filter.search;
}
});
hook(name, handler, priority?)
Register a global hook.
api.hook('beforeInsert', async (context) => {
// Runs for all resources
}, 10); // Priority: lower = earlier
mount(app, basePath?)
Mount the API on an Express app (requires HTTPPlugin).
api.mount(expressApp, '/api');
CRUD Methods
There are two ways to call CRUD methods: through the Resource Proxy API (recommended) or through API-level methods.
API Calling Methods Comparison
Resource Proxy API (Recommended):
// Direct, intuitive access through api.resources
await api.resources.users.get(123);
await api.resources.users.create({ name: 'John' });
await api.resources.users.query({ filter: { active: true } });
await api.resources.users.update(123, { name: 'John Doe' });
await api.resources.users.delete(123);
API-Level Methods:
// Specify resource type in options
await api.get(123, { type: 'users' });
await api.insert({ name: 'John' }, { type: 'users' });
await api.query({ filter: { active: true } }, { type: 'users' });
await api.update(123, { name: 'John Doe' }, { type: 'users' });
await api.delete(123, { type: 'users' });
The Resource Proxy API is preferred because:
- More intuitive and readable syntax
- Better TypeScript/IDE support
- Natural chaining for relationships
- Cleaner code for complex operations
API-level methods are useful when:
- Resource type is dynamic or determined at runtime
- Building generic utilities that work with multiple resources
- Migrating from older codebases
get(id, options)
// Resource Proxy API (recommended)
const user = await api.resources.users.get(123);
// API-level method
const user = await api.get(123, { type: 'users' });
query(params, options)
// Resource Proxy API (recommended)
const users = await api.resources.users.query({
filter: { active: true },
sort: [{ field: 'name', direction: 'ASC' }],
page: { size: 20, number: 1 }
});
// API-level method
const users = await api.query({
filter: { active: true },
sort: [{ field: 'name', direction: 'ASC' }],
page: { size: 20, number: 1 }
}, { type: 'users' });
insert(data, options)
/ create(data, options)
// Resource Proxy API (recommended)
const newUser = await api.resources.users.create({
name: 'John',
email: 'john@example.com'
});
// API-level method
const newUser = await api.insert({
name: 'John',
email: 'john@example.com'
}, { type: 'users' });
update(id, data, options)
// Resource Proxy API (recommended)
const updated = await api.resources.users.update(123, {
name: 'John Doe'
});
// API-level method
const updated = await api.update(123, {
name: 'John Doe'
}, { type: 'users' });
delete(id, options)
// Resource Proxy API (recommended)
await api.resources.users.delete(123);
// API-level method
await api.delete(123, { type: 'users' });
Schema Class
Defines the structure and validation rules for resources.
Constructor
new Schema(structure: SchemaStructure)
Schema Structure
{
fieldName: {
type: 'string', // Required
required: true, // Optional
default: 'value', // Optional
min: 1, // Optional (string length or number value)
max: 100, // Optional
unique: true, // Optional
silent: true, // Optional - exclude from default SELECT
searchable: true, // Optional - allow filtering by this field
format: 'email', // Optional - format validation (see formats below)
enum: ['a', 'b', 'c'], // Optional - allowed values
validator: (val) => {}, // Optional - custom validation function
trim: true, // Optional - trim whitespace (strings)
uppercase: true, // Optional - convert to uppercase
lowercase: true, // Optional - convert to lowercase
notEmpty: true, // Optional - disallow empty strings
maxItems: 100, // Optional - max array length
maxKeys: 50, // Optional - max object properties
maxDepth: 5, // Optional - max object nesting
refs: { // Optional - foreign key reference
resource: 'users',
join: { // Optional - automatic join config
eager: true,
fields: ['id', 'name']
},
provideUrl: true // Optional - enable relationship endpoints
}
}
}
Field Parameters
Parameter | Types | Description |
---|---|---|
type |
all | Field type (required) |
required |
all | Field must be present |
default |
all | Default value or function |
min |
string, number | Minimum length/value |
max |
string, number | Maximum length/value |
unique |
all | Enforce uniqueness |
silent |
all | Exclude from SELECT |
virtual |
all | Computed field, not stored |
searchable |
all | Allow filtering |
format |
string | Format validation |
enum |
all | Allowed values |
validator |
all | Custom validation |
trim |
string | Trim whitespace |
uppercase |
string | Convert to uppercase |
lowercase |
string | Convert to lowercase |
notEmpty |
string | Disallow empty strings |
maxItems |
array | Maximum array length |
maxKeys |
object | Maximum object properties |
maxDepth |
object | Maximum nesting depth |
permissions |
all | Field-level access control |
Format Validation
The format
parameter provides safe regex validation with ReDoS protection:
Format | Description | Example |
---|---|---|
email |
Email address | user@example.com |
url |
HTTP/HTTPS URL | https://example.com |
uuid |
UUID v4 | 123e4567-e89b-12d3-a456-426614174000 |
alphanumeric |
Letters and numbers | abc123 |
slug |
URL-friendly string | my-cool-page |
date |
Date (YYYY-MM-DD) | 2024-01-15 |
time |
Time (HH:MM[:SS]) | 14:30 or 14:30:45 |
phone |
Phone number | +1 (555) 123-4567 |
postalCode |
Postal/ZIP code | 12345 or A1B 2C3 |
Example:
const schema = new Schema({
email: {
type: 'string',
required: true,
format: 'email' // Safe email validation
},
website: {
type: 'string',
format: 'url' // ReDoS-protected URL validation
},
tags: {
type: 'array',
maxItems: 10 // Prevent DoS from huge arrays
}
});
Field Permissions
The permissions
parameter provides fine-grained access control at the field level:
const schema = new Schema({
// Public field - anyone can read
name: { type: 'string' },
// Role-based permission
email: {
type: 'string',
permissions: { read: 'authenticated' }
},
// Multiple roles (OR logic)
salary: {
type: 'number',
permissions: { read: ['hr', 'manager', 'admin'] }
},
// Never readable
password: {
type: 'string',
permissions: { read: false }
},
// Function-based permission
notes: {
type: 'string',
permissions: {
read: (user, record) => {
return user?.id === record.authorId || user?.roles?.includes('admin');
}
}
},
// Separate permissions for operations
status: {
type: 'string',
permissions: {
read: true, // Anyone can read
write: 'admin', // Only admin can write
include: 'authenticated' // Must be logged in to include in relationships
}
}
});
Permission Types
- Boolean:
true
(public) orfalse
(never allowed) - String: Role name that user must have
- Array: List of roles (user needs at least one)
- Function:
(user, record) => boolean
for custom logic
Permission Operations
read
: Controls if field is included in responseswrite
: Controls if field can be set/updatedinclude
: Controls if relationship can be included viainclude
parameter
Permission Context
The permission system automatically provides context to help make access decisions:
api.hook('transformResult', async (context) => {
// Context includes:
// - context.options.user: The authenticated user object
// - context.options.type: The resource type
// - context.result: The resource being accessed
// - context.options.isJoinResult: Whether this is joined data
});
Virtual Fields
Virtual fields are computed properties that are not stored in the database. They are populated by hooks (typically afterGet
) and can be used to add derived data to your resources.
const productSchema = new Schema({
name: { type: 'string', required: true },
cost: { type: 'number', required: true },
price: { type: 'number', required: true },
// Virtual fields - computed, not stored
profit: {
type: 'number',
virtual: true
},
margin: {
type: 'string',
virtual: true,
permissions: { read: 'manager' } // Can have permissions
}
});
// Populate virtual fields with afterGet hook
api.hook('afterGet', async (context) => {
if (context.options.type === 'products') {
const product = context.result;
product.profit = product.price - product.cost;
product.margin = `${Math.round((product.profit / product.price) * 100)}%`;
}
});
Virtual Field Characteristics
- Not stored: Values are never saved to the database
- Not queryable: Cannot be used in filters or searches
- Computed on read: Populated by hooks when resources are fetched
- Support permissions: Can have field-level permissions like regular fields
- Work everywhere: Populated for single gets, queries, and included resources
- Excluded from validation: Virtual fields are automatically skipped during validation
- Excluded from inserts/updates: Virtual fields are removed before database operations
To-Many Virtual Fields
Virtual fields with type: 'list'
enable to-many relationships:
const authorSchema = new Schema({
name: { type: 'string', required: true },
posts: {
type: 'list',
virtual: true,
foreignResource: 'posts',
foreignKey: 'authorId',
defaultFilter: { published: true },
defaultSort: '-createdAt',
limit: 100
}
});
Field Types
Type | Description | MySQL Type | Notes |
---|---|---|---|
'id' |
Auto-incrementing ID | INT AUTO_INCREMENT | Primary key |
'string' |
Text field | VARCHAR(255) | Support for validation formats |
'blob' |
Binary data | BLOB | For binary content |
'number' |
Numeric field | DOUBLE | Integer or decimal |
'boolean' |
True/false | BOOLEAN | Accepts truthy/falsy values |
'timestamp' |
Unix timestamp | BIGINT | Milliseconds since epoch |
'date' |
Date (YYYY-MM-DD) | DATE | Date only, no time |
'dateTime' |
Date and time | DATETIME | Full date and time |
'json' |
JSON data | TEXT | Stored as JSON string |
'array' |
Array (stored as JSON) | TEXT | With size limits |
'object' |
Object (stored as JSON) | TEXT | With depth/key limits |
'serialize' |
Circular object | TEXT | Handles circular refs |
'list' |
To-many relationship (virtual) | Not stored | Virtual only |
'none' |
No type validation | Varies | Pass-through type |
Relationships
The library supports both to-one and to-many relationships with automatic loading and JSON:API compliant endpoints.
To-One Relationships (refs)
To-one relationships are defined using the refs
property on ID fields:
const postSchema = new Schema({
title: { type: 'string', required: true },
authorId: {
type: 'id',
refs: {
resource: 'users', // Related resource type
join: { // Optional auto-join configuration
eager: true, // Always include when queried
fields: ['id', 'name'], // Specific fields to include
preserveId: true // Keep both ID and joined object
},
provideUrl: true // Enable relationship endpoints
}
}
});
To-Many Relationships (list)
To-many relationships are defined using type: 'list'
with virtual fields:
const userSchema = new Schema({
name: { type: 'string', required: true },
posts: {
type: 'list',
virtual: true, // Not stored in users table
foreignResource: 'posts', // Related resource type
foreignKey: 'authorId', // Field in related resource
defaultFilter: { published: true }, // Optional default filter
defaultSort: '-createdAt', // Optional default sort
provideUrl: true // Enable relationship endpoints
}
});
Relationship Endpoints
When provideUrl: true
is set, the following endpoints become available:
For To-One Relationships:
GET /posts/1/relationships/author
- Get relationship linkageGET /posts/1/author
- Get full related resourcePATCH /posts/1/relationships/author
- Update relationship
For To-Many Relationships:
GET /users/1/relationships/posts
- Get relationship linkagesGET /users/1/posts
- Get full related resourcesPOST /users/1/relationships/posts
- Add to relationshipDELETE /users/1/relationships/posts
- Remove from relationship
Loading Relationships
Use the include
parameter to load related resources:
// Include author when getting a post
const post = await api.get(1, {
type: 'posts',
include: 'author'
});
// Include posts when getting a user
const user = await api.get(1, {
type: 'users',
include: 'posts'
});
// Nested includes
const post = await api.get(1, {
type: 'posts',
include: 'author.country'
});
Methods
validate(data, options?)
Validate data against the schema.
const errors = schema.validate(userData);
if (errors.length > 0) {
throw new ValidationError(errors);
}
Options:
partial
: boolean - Allow partial data (for updates)skipRequired
: boolean - Skip required field validation
Searchable Fields and Filtering
Defining Searchable Fields
Fields must be explicitly marked as searchable to enable filtering:
const userSchema = new Schema({
// Basic searchable fields
name: { type: 'string', searchable: true },
email: { type: 'string', searchable: true },
age: { type: 'number', searchable: true },
active: { type: 'boolean', searchable: true },
// Arrays can be searchable too
tags: { type: 'array', searchable: true },
// Non-searchable fields cannot be filtered
password: { type: 'string' }, // Not searchable
internalNotes: { type: 'string' } // Not searchable
});
Mapped Searchable Fields
Create virtual search fields that map to relationships:
api.addResource('posts', postSchema, {
searchableFields: {
// Map author name to authorId relationship
'author': 'authorId.name',
'authorEmail': 'authorId.email',
// Map category to categoryId relationship
'category': 'categoryId.title',
// Virtual search field (marked with *)
'search': '*'
}
});
// Now you can filter by relationship fields:
// GET /api/posts?filter[author]=John
// GET /api/posts?filter[category]=Technology
Virtual Search Fields
Fields marked with *
require custom handlers:
api.addResource('posts', postSchema, {
searchableFields: {
'search': '*' // Virtual field
}
});
// Implement the search logic
api.hook('modifyQuery', async (context) => {
if (context.params.filter?.search && context.options.type === 'posts') {
const searchTerm = context.params.filter.search;
// Add custom search conditions
context.query.where(
'(posts.title LIKE ? OR posts.content LIKE ? OR posts.tags LIKE ?)',
`%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`
);
// Remove from filter to prevent column lookup
delete context.params.filter.search;
}
});
// Usage: GET /api/posts?filter[search]=javascript
Filter Operators Reference
All operators require the field to be searchable:
// Equality (default)
?filter[status]=active
// Not equal
?filter[status][ne]=draft
// Comparison operators
?filter[age][gt]=18 // Greater than
?filter[age][gte]=18 // Greater than or equal
?filter[age][lt]=65 // Less than
?filter[age][lte]=65 // Less than or equal
// Range
?filter[age][between]=18,65
// Pattern matching
?filter[name][like]=%john% // SQL LIKE
?filter[email][ilike]=%@GMAIL% // Case-insensitive
?filter[name][startsWith]=John // Starts with
?filter[email][endsWith]=.com // Ends with
?filter[bio][contains]=developer // Contains substring
// Array operators
?filter[status][in]=active,pending // IN
?filter[role][nin]=guest,banned // NOT IN
?filter[tags][contains]=javascript // Array contains
?filter[permissions][contained]=read,write // Array contained by
?filter[skills][overlaps]=js,python // Arrays overlap
// NULL checks
?filter[deletedAt][null]=true // IS NULL
?filter[email][notnull]=true // IS NOT NULL
Relationships
The API supports both to-one and to-many relationships between resources.
To-One Relationships (refs)
Define foreign key relationships using the refs
property:
api.addResource('posts', new Schema({
title: { type: 'string', required: true },
authorId: {
type: 'id',
refs: {
resource: 'users',
join: {
eager: true, // Auto-include when fetching
fields: ['id', 'name'], // Select specific fields
preserveId: true // Keep both ID and joined data
}
}
}
}));
To-Many Relationships
Define inverse relationships using the list
type:
api.addResource('users', new Schema({
name: { type: 'string', required: true },
posts: {
type: 'list',
virtual: true, // Not stored in database
foreignResource: 'posts', // Related resource type
foreignKey: 'authorId', // Field in related resource
// Optional configuration:
defaultFilter: { published: true }, // Auto-filter
defaultSort: '-createdAt', // Auto-sort
limit: 100, // Max results
permissions: { include: 'authenticated' } // Include permission
}
}));
// The foreign key must be searchable
api.addResource('posts', new Schema({
title: { type: 'string' },
authorId: {
type: 'id',
refs: { resource: 'users' },
searchable: true // Required for to-many queries
},
published: { type: 'boolean', searchable: true },
createdAt: { type: 'timestamp', searchable: true }
}));
Including Relationships
Use the include
parameter to load related resources:
// Include single relationship
GET /users/1?include=profile
// Include multiple relationships
GET /users/1?include=profile,posts
// Include nested relationships (to-one only)
GET /posts/1?include=author.country
// Include to-many relationships
GET /users/1?include=posts // Returns user with all their posts
Relationship Permissions
Control who can include relationships:
countryId: {
type: 'id',
refs: { resource: 'countries' },
permissions: {
read: true, // Anyone can see the ID
include: 'authenticated' // Must be logged in to include full data
}
}
Query Parameters
The API supports JSON:API compliant query parameters for filtering, sorting, pagination, and more:
Filtering
Use the filter
parameter to filter results. Fields must be marked searchable: true
in the schema:
// Schema
new Schema({
name: { type: 'string', searchable: true },
age: { type: 'number', searchable: true },
email: { type: 'string' } // Not searchable
})
// Simple filtering
?filter[name]=John
?filter[age]=25
// Advanced operators
?filter[age][gte]=18 // Greater than or equal
?filter[age][lt]=65 // Less than
?filter[name][like]=%john% // SQL LIKE
?filter[tags][contains]=javascript // Array contains
?filter[status][in]=active,pending // IN clause
?filter[email][ne]=null // Not equal (NOT NULL)
Available Operators
Operator | Description | Example |
---|---|---|
(none) | Equals | filter[name]=John |
ne |
Not equals | filter[status][ne]=deleted |
gt |
Greater than | filter[age][gt]=18 |
gte |
Greater than or equal | filter[age][gte]=18 |
lt |
Less than | filter[age][lt]=65 |
lte |
Less than or equal | filter[age][lte]=65 |
like |
SQL LIKE | filter[name][like]=%john% |
ilike |
Case-insensitive LIKE | filter[email][ilike]=%@EXAMPLE.COM |
in |
IN clause | filter[status][in]=active,pending |
nin |
NOT IN | filter[role][nin]=guest,banned |
between |
Between two values | filter[age][between]=18,65 |
contains |
Array contains | filter[tags][contains]=javascript |
contained |
Array is contained by | filter[permissions][contained]=read,write |
overlaps |
Arrays overlap | filter[skills][overlaps]=js,python |
null |
IS NULL | filter[deletedAt][null]=true |
notnull |
IS NOT NULL | filter[email][notnull]=true |
Sorting
Use the sort
parameter to order results:
// Single field ascending
?sort=name
// Single field descending (prefix with -)
?sort=-createdAt
// Multiple fields
?sort=-createdAt,name
// Sort on relationship fields (if relationship is included)
?sort=author.name&include=author
Pagination
Use the page
parameter for pagination:
// Page-based pagination (JSON:API style)
?page[size]=20&page[number]=2
// Limit/offset style
?limit=20&offset=40
Sparse Fieldsets
Use the fields
parameter to request only specific fields:
// Only return name and email for users
?fields[users]=name,email
// Different fields for different resource types
?fields[users]=name,email&fields[posts]=title,createdAt
Including Relationships
Use the include
parameter to include related resources:
// Single relationship
?include=author
// Multiple relationships
?include=author,category
// Nested relationships (dot notation)
?include=author.country
// Multiple levels
?include=author.country,comments.author
Include Permissions
Relationships can have include permissions that control access:
new Schema({
authorId: {
type: 'id',
refs: { resource: 'authors' },
permissions: {
read: true, // Anyone can see the ID
include: 'authenticated' // Must be logged in to include full author data
}
}
})
Views (with ViewsPlugin)
Use the view
parameter to request predefined response shapes:
// Use a named view
?view=summary
// Views are defined in resource configuration
api.addResource('posts', schema, {
views: {
summary: {
fields: ['id', 'title', 'createdAt'],
joins: []
},
full: {
fields: true, // All fields
joins: ['authorId', 'categoryId']
}
}
})
Complete Example
// Complex query with all parameters
GET /api/posts?
filter[status]=published&
filter[createdAt][gte]=2024-01-01&
sort=-createdAt&
page[size]=10&
page[number]=1&
fields[posts]=title,summary,createdAt&
fields[users]=name,avatar&
include=author,category&
view=card
QueryBuilder Class
Fluent interface for building SQL queries.
Constructor
new QueryBuilder(table: string, api?: Api)
Methods
select(...fields)
Add fields to SELECT clause.
query.select('id', 'name', 'email');
query.select('COUNT(*) as total');
where(condition, ...args)
Add WHERE condition.
query.where('active = ?', true);
query.where('age BETWEEN ? AND ?', 18, 65);
join(type, tableOrField, on?)
Add JOIN clause. If on
is omitted and the field has refs, uses automatic join.
// Manual join
query.leftJoin('comments', 'comments.userId = users.id');
// Automatic join using refs
query.leftJoin('authorId'); // Uses schema refs
includeRelated(fieldName, fields?)
Include fields from a related resource.
// Include all non-silent fields
query.includeRelated('authorId');
// Include specific fields with auto-prefix
query.includeRelated('authorId', ['name', 'email']);
// Selects: users.name as authorId_name, users.email as authorId_email
// Include with custom aliases
query.includeRelated('authorId', {
name: 'authorName', // Custom alias
email: true, // Auto-prefix: authorId_email
avatar: 'userAvatar' // Custom alias
});
orderBy(field, direction?)
Add ORDER BY clause.
query.orderBy('createdAt', 'DESC');
query.orderBy('name'); // Default: ASC
groupBy(...fields)
Add GROUP BY clause.
query.groupBy('userId', 'status');
having(condition, ...args)
Add HAVING clause.
query.having('COUNT(*) > ?', 5);
limit(limit, offset?)
Add LIMIT clause.
query.limit(20); // LIMIT 20
query.limit(20, 40); // LIMIT 40, 20 (MySQL syntax)
toSQL()
Generate the final SQL query.
const sql = query.toSQL();
// SELECT * FROM users WHERE active = ? ORDER BY name LIMIT 20
getArgs()
Get query parameter arguments.
const args = query.getArgs(); // [true]
Resource Proxy API
The recommended way to interact with resources, providing an intuitive object-oriented interface.
Accessing Resources
// Get resource proxy
const users = api.resources.users;
// Check if resource exists
if ('users' in api.resources) {
console.log('Users resource is available');
}
// Access resource schema
const userSchema = api.resources.users.schema;
// Access resource hooks
const userHooks = api.resources.users.hooks;
// Get resource type name
const typeName = api.resources.users.type; // 'users'
Advanced Proxy Features
Schema Access
// Get field definitions
const fields = api.resources.users.schema.structure;
// Check if field exists
if ('email' in api.resources.users.schema.structure) {
const emailDef = api.resources.users.schema.structure.email;
console.log(`Email type: ${emailDef.type}`);
}
// Validate data against schema
const errors = await api.resources.users.schema.validate(userData);
Hook Access
// Access resource-specific hooks
api.resources.users.hooks.afterInsert = async (context) => {
// Send welcome email
await sendEmail(context.result.email, 'Welcome!');
};
// Check if hook exists
if (api.resources.users.hooks.beforeUpdate) {
console.log('Users have a beforeUpdate hook');
}
Batch Operations
// Bulk operations via proxy
const bulkUsers = api.resources.users.bulk;
// Bulk create
await bulkUsers.create([
{ name: 'User 1', email: 'user1@example.com' },
{ name: 'User 2', email: 'user2@example.com' }
]);
// Bulk update
await bulkUsers.update([
{ id: 1, data: { verified: true } },
{ id: 2, data: { verified: true } }
]);
// Bulk delete
await bulkUsers.delete([3, 4, 5]);
CRUD Methods
get(id, options?)
Get a single resource by ID.
// Simple get
const user = await api.resources.users.get(123);
// With includes
const userWithIncludes = await api.resources.users.get(123, {
include: 'departmentId,posts'
});
// With nested includes
const userWithNested = await api.resources.users.get(123, {
include: 'departmentId.countryId,posts.categoryId'
});
// Handle not found gracefully
const maybeUser = await api.resources.users.get(999, {
allowNotFound: true // Returns null instead of throwing
});
query(params?, options?)
Query multiple resources with powerful filtering and sorting.
// Basic query
const users = await api.resources.users.query();
// With filtering (only searchable fields)
const activeAdmins = await api.resources.users.query({
filter: {
active: true,
role: 'admin',
createdAt: { gte: '2024-01-01' }
}
});
// Advanced filtering with operators
const results = await api.resources.users.query({
filter: {
age: { between: [18, 65] },
email: { endsWith: '@company.com' },
tags: { contains: 'premium' },
deletedAt: { null: true },
name: { ilike: '%john%' } // Case-insensitive
}
});
// Sorting
const sorted = await api.resources.posts.query({
sort: [{ field: 'createdAt', direction: 'DESC' }]
});
// String sort syntax
const sorted2 = await api.resources.posts.query({
sort: '-createdAt,title' // DESC createdAt, ASC title
});
// Pagination
const page2 = await api.resources.posts.query({
page: { size: 20, number: 2 }
});
// Include related data
const withRelated = await api.resources.posts.query({
include: 'authorId,categoryId,comments'
});
// Use a view (requires ViewsPlugin)
const summary = await api.resources.posts.query({
view: 'summary'
});
// Complex query example
const complexQuery = await api.resources.posts.query({
filter: {
status: 'published',
authorId: { in: [1, 2, 3] },
tags: { overlaps: ['tech', 'news'] }
},
sort: '-views,title',
page: { size: 10, number: 1 },
include: 'authorId.departmentId,categoryId',
view: 'detailed'
});
Query Parameters:
filter
: Object with field/value pairs (fields must be markedsearchable: true
)sort
: Array of sort objects or string (e.g., ‘-createdAt,name’)page
: Object withsize
andnumber
for paginationinclude
: Comma-separated relationships (supports dot notation)view
: String name of a predefined view (requires ViewsPlugin)fields
: Sparse fieldsets (e.g.,{ posts: 'title,summary' }
)
create(data, options?)
/ post(data, options?)
Create a new resource.
const newUser = await api.resources.users.create({
name: 'John',
email: 'john@example.com'
});
update(id, data, options?)
/ put(id, data, options?)
Update a resource.
const updated = await api.resources.users.update(123, {
name: 'John Doe'
});
delete(id, options?)
/ remove(id, options?)
Delete a resource.
await api.resources.users.delete(123);
Options
All methods accept an options object:
Option | Type | Description |
---|---|---|
include |
string | Comma-separated list of relationships to include (supports nested paths) |
excludeJoins |
string[] | Exclude specific eager joins |
artificialDelay |
number | Override delay for this operation |
allowNotFound |
boolean | Don’t throw if resource not found (get only) |
skipValidation |
boolean | Skip schema validation |
partial |
boolean | Allow partial data (update only) |
fullRecord |
boolean | Require complete record (PUT semantics) |
Nested Includes
The include
parameter supports dot notation for multi-level relationships:
// Single level
include: 'authorId,categoryId'
// Nested (two levels)
include: 'authorId.countryId'
// Multiple nested paths
include: 'authorId.countryId,editorId.departmentId'
// Three levels deep
include: 'authorId.departmentId.countryId'
Requirements:
- Each field in the path must have
refs
configuration - Parent relationships are automatically included
- Invalid paths throw
BadRequestError
with details - Permission checks occur at each level
- Hooks execute from innermost to outermost level
Transaction API
High-level transaction support for database operations.
api.transaction(fn, options)
Execute operations within a database transaction.
// Basic transaction
const result = await api.transaction(async (trx) => {
const user = await trx.resources.users.create({ name: 'Alice' });
const account = await trx.resources.accounts.create({
userId: user.id,
balance: 1000
});
return { user, account };
});
// With options
const result = await api.transaction(async (trx) => {
// Operations here
}, {
timeout: 5000, // Transaction timeout in ms
retries: 3, // Number of retry attempts
isolationLevel: 'READ COMMITTED'
});
Transaction Methods
trx.savepoint(name, fn)
Create a savepoint within a transaction.
await api.transaction(async (trx) => {
const user = await trx.resources.users.create({ name: 'Bob' });
try {
await trx.savepoint('risky_operation', async () => {
// If this fails, only rolls back to savepoint
await trx.resources.accounts.create({ userId: user.id, balance: -100 });
});
} catch (error) {
// User creation is preserved
}
});
Requirements
- Storage adapter must support transactions (MySQL, PostgreSQL)
- Memory storage gracefully degrades (no actual transactions)
- Nested transactions are supported via savepoints
- All operations within transaction share the same connection
Batch Operations API
Execute multiple operations efficiently.
api.batch(operations, options)
Execute multiple mixed operations.
const results = await api.batch([
{ method: 'create', type: 'users', data: { name: 'Alice' } },
{ method: 'create', type: 'users', data: { name: 'Bob' } },
{ method: 'update', type: 'products', id: 123, data: { price: 99.99 } },
{ method: 'delete', type: 'orders', id: 456 }
], {
stopOnError: false, // Continue on failures
parallel: true // Execute independent operations in parallel
});
// Results structure
{
results: [
{ success: true, data: { id: 1, name: 'Alice' }, operation: {...} },
{ success: true, data: { id: 2, name: 'Bob' }, operation: {...} },
{ success: true, data: { id: 123, price: 99.99 }, operation: {...} },
{ success: false, error: {...}, operation: {...} }
],
successful: 3,
failed: 1
}
api.batch.transaction(fn, options)
Execute batch operations within a transaction.
const results = await api.batch.transaction(async (batch) => {
// All operations share the same transaction
await batch.resources.accounts.create([
{ name: 'Checking', balance: 1000 },
{ name: 'Savings', balance: 5000 }
]);
await batch.resources.users.update([
{ id: 1, data: { verified: true } },
{ id: 2, data: { verified: true } }
]);
});
Bulk Operations API
Optimized operations for multiple records of the same type.
resources.{type}.bulk.create(items, options)
Bulk create multiple records.
const users = await api.resources.users.bulk.create([
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
{ name: 'Charlie', email: 'charlie@example.com' }
], {
chunk: 1000, // Process in chunks
validate: true, // Validate all before inserting
returnIds: true // Return generated IDs
});
resources.{type}.bulk.update(updates, options)
Bulk update multiple records.
// Update specific records
const results = await api.resources.products.bulk.update([
{ id: 1, data: { price: 19.99 } },
{ id: 2, data: { price: 29.99 } },
{ id: 3, data: { price: 39.99 } }
]);
// Update by filter
const result = await api.resources.products.bulk.update({
filter: { category: 'electronics' },
data: { discounted: true }
});
// Returns: { updated: 42 }
resources.{type}.bulk.delete(idsOrFilter, options)
Bulk delete multiple records.
// Delete by IDs
const results = await api.resources.users.bulk.delete([1, 2, 3]);
// Delete by filter
const result = await api.resources.users.bulk.delete({
filter: { inactive: true, lastLogin: { lt: '2023-01-01' } }
});
// Returns: { deleted: 156 }
Performance Notes
- Bulk operations use optimized SQL (single INSERT/UPDATE/DELETE)
- Memory storage falls back to individual operations
- Progress callbacks available for large datasets
- Chunking prevents memory issues
Connection Pool Configuration
Configure database connection pooling for optimal performance.
Pool Options (MySQL)
const api = createApi({
storage: 'mysql',
mysql: {
host: 'localhost',
user: 'root',
password: 'password',
database: 'myapp',
pool: {
max: 20, // Maximum connections
min: 5, // Minimum connections
acquireTimeout: 30000, // Max time to acquire connection (ms)
idleTimeout: 60000, // Time before idle connection is closed
connectionLimit: 100, // Hard limit on total connections
queueLimit: 0, // Max queued requests (0 = unlimited)
enableKeepAlive: true, // TCP keep-alive
keepAliveInitialDelay: 0 // Keep-alive delay (ms)
}
}
});
Pool Monitoring
// Get pool statistics
const stats = await api.getPoolStats();
{
total: 20, // Total connections
active: 5, // Currently in use
idle: 15, // Available connections
waiting: 0, // Requests waiting for connection
timeout: 30000, // Acquire timeout
created: 20, // Total connections created
destroyed: 0 // Total connections destroyed
}
// Monitor pool events
api.on('pool:acquire', (connection) => {
console.log('Connection acquired:', connection.threadId);
});
api.on('pool:release', (connection) => {
console.log('Connection released:', connection.threadId);
});
api.on('pool:timeout', (info) => {
console.error('Pool timeout:', info);
});
Best Practices
- Connection Limits: Set
max
based on database server limits - Timeouts: Balance between responsiveness and connection reuse
- Monitoring: Track pool stats in production
- Graceful Shutdown: Always call
api.disconnect()
on shutdown
Plugin Interface
Plugins extend API functionality.
Plugin Structure
const MyPlugin = {
name: 'MyPlugin', // Optional but recommended
install(api, options) {
// Register hooks
api.hook('beforeInsert', handler);
// Implement storage methods
api.implement('get', getImplementation);
// Add API methods
api.myMethod = () => { };
// Store plugin state
api.myPluginData = { };
}
};
Storage Implementation
Plugins can implement these methods:
get(context)
- Get single resourcequery(context)
- Query multiple resourcesinsert(context)
- Create resourceupdate(context)
- Update resourcedelete(context)
- Delete resource
Context includes:
{
api, // API instance
method, // Method name
id, // Resource ID (get/update/delete)
data, // Resource data (insert/update)
params, // Query parameters (query)
options, // Operation options
result // Result (in after* hooks)
}
Built-in Storage Plugins
MemoryPlugin
In-memory SQL database using AlaSQL, perfect for development and testing.
import { MemoryPlugin } from 'json-rest-api';
api.use(MemoryPlugin);
Features:
- Zero configuration required
- Full SQL support (SELECT, JOIN, GROUP BY, etc.)
- Automatic table creation
- No persistence (data lost on restart)
- Perfect for testing and prototyping
- Supports transactions (simulated)
- JSON and array field support
Use Cases:
- Development and prototyping
- Unit testing
- Demo applications
- Temporary data processing
MySQLPlugin
Production-ready MySQL/MariaDB storage with connection pooling.
import { MySQLPlugin } from 'json-rest-api';
api.use(MySQLPlugin, {
connection: {
host: 'localhost',
user: 'root',
password: 'password',
database: 'myapp',
// Connection pool settings
connectionLimit: 10,
queueLimit: 0,
waitForConnections: true
},
syncSchemas: true, // Auto-create/update tables
useNamedPlaceholders: true // Use :name instead of ?
});
Features:
- Connection pooling for performance
- Real transactions with savepoints
- Automatic schema synchronization
- JSON column support (MySQL 5.7+)
- Prepared statements for security
- Named placeholders support
- Graceful connection handling
Connection Options:
{
host: 'localhost',
port: 3306,
user: 'root',
password: 'password',
database: 'myapp',
charset: 'utf8mb4',
timezone: 'local',
ssl: {
ca: fs.readFileSync('server-ca.pem'),
cert: fs.readFileSync('client-cert.pem'),
key: fs.readFileSync('client-key.pem')
},
// Pool configuration
connectionLimit: 10,
acquireTimeout: 60000,
queueLimit: 0
}
Schema Synchronization:
With syncSchemas: true
, the plugin automatically:
- Creates missing tables
- Adds missing columns
- Adjusts column types if needed
- Preserves existing data
// Tables are created/updated when resources are added
api.addResource('users', userSchema); // Creates 'users' table
Built-in Plugins
CorsPlugin
Automatic CORS configuration with platform detection.
Basic Usage
import { CorsPlugin } from 'json-rest-api/plugins/cors.js';
// Zero configuration - works automatically
api.use(CorsPlugin);
// With options
api.use(CorsPlugin, {
cors: {
origin: ['https://myapp.com', 'https://www.myapp.com']
}
});
Configuration Options
Option | Type | Default | Description |
---|---|---|---|
cors |
object | function | auto-detect | CORS configuration or validation function |
debug |
boolean | false |
Enable debug logging |
CORS Configuration Object
Property | Type | Default | Description |
---|---|---|---|
origin |
string | string[] | RegExp | function | boolean | auto | Allowed origins |
credentials |
boolean | true |
Allow credentials |
methods |
string[] | ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] |
Allowed methods |
allowedHeaders |
string[] | ['Content-Type', 'Authorization', 'X-Requested-With'] |
Allowed headers |
exposedHeaders |
string[] | ['X-Total-Count', 'Link', 'X-Request-ID'] |
Exposed headers |
maxAge |
number | 86400 |
Preflight cache time (seconds) |
Auto-Detection Features
- Development Mode (NODE_ENV !== ‘production’):
- Allows all localhost variations
- Allows local network IPs
- Allows common development tools
- Platform Detection:
- Vercel, Netlify, Heroku, AWS Amplify
- Railway, Render, Google Cloud Run
- Azure, DigitalOcean, Fly.io
- Cloudflare, GitHub Codespaces, Gitpod
- And many more…
- Environment Variables (checked in order):
CORS_ORIGINS
/CORS_ORIGIN
ALLOWED_ORIGINS
/ALLOWED_ORIGIN
FRONTEND_URL
/CLIENT_URL
APP_URL
/WEB_URL
/PUBLIC_URL
Usage Examples
// Dynamic validation
api.use(CorsPlugin, {
cors: async (origin, callback) => {
const allowed = await checkOriginInDatabase(origin);
callback(null, allowed);
}
});
// Regex pattern
api.use(CorsPlugin, {
cors: {
origin: /^https:\/\/[a-z]+\.example\.com$/
}
});
// Public API (no credentials)
api.use(CorsPlugin, {
cors: {
origin: '*',
credentials: false // Required with wildcard
}
});
JwtPlugin
JSON Web Token authentication with refresh token support.
Basic Usage
import { JwtPlugin } from 'json-rest-api/plugins/jwt.js';
api.use(JwtPlugin, {
secret: process.env.JWT_SECRET
});
// Generate tokens
const token = await api.generateToken({
userId: 123,
email: 'user@example.com',
roles: ['user']
});
// Verify tokens
const payload = await api.verifyToken(token);
Configuration Options
Option | Type | Default | Description |
---|---|---|---|
secret |
string | - | Secret key for HMAC algorithms |
privateKey |
string | - | Private key for RSA/ECDSA algorithms |
publicKey |
string | - | Public key for RSA/ECDSA algorithms |
algorithm |
string | 'HS256' |
JWT algorithm |
expiresIn |
string | '24h' |
Token expiration time |
refreshExpiresIn |
string | '30d' |
Refresh token expiration |
issuer |
string | 'json-rest-api' |
Token issuer |
audience |
string | - | Token audience |
clockTolerance |
number | 30 |
Clock skew tolerance (seconds) |
refreshTokenLength |
number | 32 |
Refresh token bytes |
supportLegacyTokens |
boolean | false |
Support Base64 JSON tokens |
tokenHeader |
string | - | Custom header for token |
tokenQueryParam |
string | - | Query parameter for token |
tokenCookie |
string | - | Cookie name for token |
tokenStore |
Map | object | new Map() |
Storage for refresh tokens |
beforeSign |
function | - | Hook before signing |
afterVerify |
function | - | Hook after verification |
onRefresh |
function | - | Hook on token refresh |
API Methods
generateToken(payload, options?)
Generate a JWT token.
const token = await api.generateToken(
{ userId: 123, role: 'admin' },
{ expiresIn: '1h' }
);
verifyToken(token, options?)
Verify and decode a JWT token.
try {
const payload = await api.verifyToken(token);
} catch (error) {
// Token expired, invalid, etc.
}
generateRefreshToken(userId, metadata?)
Generate a refresh token.
const refreshToken = await api.generateRefreshToken(123, {
deviceId: 'device-123',
userAgent: req.headers['user-agent']
});
refreshAccessToken(refreshToken)
Exchange refresh token for new access token.
const { accessToken, refreshToken, expiresIn } =
await api.refreshAccessToken(refreshToken);
revokeRefreshToken(refreshToken)
Revoke a refresh token.
await api.revokeRefreshToken(refreshToken);
decodeToken(token)
Decode token without verification (for debugging).
const decoded = api.decodeToken(token);
// { header: {...}, payload: {...}, signature: '...' }
Integration with HTTP
The plugin automatically extracts tokens from:
Authorization: Bearer <token>
header- Custom header (if configured)
- Query parameter (if configured)
- Cookie (if configured)
// Automatic user population
api.hook('beforeOperation', async (context) => {
// context.options.user is populated from JWT
if (context.options.user) {
console.log('Authenticated user:', context.options.user.userId);
}
});
RS256 Example
import { readFileSync } from 'fs';
api.use(JwtPlugin, {
privateKey: readFileSync('./private.key'),
publicKey: readFileSync('./public.key'),
algorithm: 'RS256'
});
AuthorizationPlugin
Role-based access control (RBAC) with ownership permissions.
Basic Usage
import { AuthorizationPlugin } from 'json-rest-api/plugins';
api.use(AuthorizationPlugin, {
// Define roles and their permissions
roles: {
admin: {
permissions: '*', // All permissions
description: 'Full system access'
},
editor: {
permissions: ['posts.*', 'media.*', 'users.read'],
description: 'Content management'
},
user: {
permissions: [
'posts.create',
'posts.read',
'posts.update.own',
'posts.delete.own'
]
}
},
// How to enhance users with roles/permissions
enhanceUser: async (user, context) => {
// Load from database, JWT, session, etc.
const roles = await loadUserRoles(user.id);
return { ...user, roles };
}
});
Configuration Options
Option | Type | Default | Description |
---|---|---|---|
enhanceUser |
function | - | Async function to load user roles/permissions |
roles |
object | {} |
Role definitions with permissions |
resources |
object | {} |
Resource-specific auth rules |
defaultRole |
string | 'user' |
Role for users with no roles |
superAdminRole |
string | 'admin' |
Role that bypasses all checks |
publicRole |
string | 'public' |
Role for unauthenticated access |
ownerField |
string | 'userId' |
Default field for ownership |
requireAuth |
boolean | true |
Require authentication by default |
Permission Syntax
// Exact permission
'posts.create'
// Wildcard - all actions on resource
'posts.*'
// Ownership suffix
'posts.update.own' // Can only update own posts
// Super wildcard - all permissions
'*'
Resource Configuration
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 permissions
publish: 'posts.publish',
feature: 'posts.feature'
}
}
}
});
Enhanced User Object
After enhancement, users have these methods:
// Check single permission
if (user.can('posts.create')) { }
// Check role
if (user.hasRole('editor')) { }
// Check multiple roles
if (user.hasAnyRole('editor', 'admin')) { }
if (user.hasAllRoles('editor', 'reviewer')) { }
Integration Examples
With Express/HTTP
// Your auth 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();
});
// Tell HTTPPlugin where to find user
api.use(HTTPPlugin, {
getUserFromRequest: (req) => req.user
});
With Direct API Usage
// Pass user in options
await api.resources.posts.update(123,
{ title: 'New' },
{ user: { id: 1, email: 'user@example.com' } }
);
Database Integration
api.use(AuthorizationPlugin, {
enhanceUser: async (user) => {
// Load from your database
const result = await db.query(
'SELECT r.name FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = ?',
[user.id]
);
return {
...user,
roles: result.map(r => r.name)
};
}
});
JWT Integration
api.use(AuthorizationPlugin, {
enhanceUser: async (user) => {
// Roles already in JWT payload
return user; // { id: 1, roles: ['editor'], permissions: ['posts.feature'] }
}
});
Field-Level Permissions
Control access to specific fields:
const schema = new Schema({
title: { type: 'string' },
content: { type: 'string' },
internalNotes: {
type: 'string',
permission: 'posts.sensitive' // Only users with this permission
}
});
Authorization Hooks
The plugin adds these hooks (priority 10):
beforeInsert
- Checks create permissionbeforeGet
- Checks read permissionbeforeQuery
- Checks read permissionbeforeUpdate
- Checks update permissionbeforeDelete
- Checks delete permissionafterGet
- Ownership verificationtransformResult
- Field-level permission filtering
Error Handling
try {
await api.resources.posts.delete(123, { user });
} catch (error) {
if (error.code === 'UNAUTHORIZED') {
// User not authenticated
} else if (error.code === 'FORBIDDEN') {
// User lacks permission
}
}
MigrationPlugin
Database schema migration support for evolving your application over time.
Basic Usage
import { MigrationPlugin } from 'json-rest-api/plugins';
api.use(MigrationPlugin, {
directory: './migrations', // Where migration files are stored
table: '_migrations', // Table to track applied migrations
autoRun: process.env.NODE_ENV === 'development' // Auto-run in dev
});
Configuration Options
Option | Type | Default | Description |
---|---|---|---|
directory |
string | './migrations' |
Directory containing migration files |
table |
string | '_migrations' |
Table name for tracking migrations |
autoRun |
boolean | false |
Automatically run pending migrations on connect |
Creating Migrations
Use the CLI to create new migrations:
npm run migrate:create add_users_table
# Creates: migrations/20240101120000_add_users_table.js
Migration file structure:
export default {
async up(api, db) {
// Forward migration
await db.execute(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await db.addIndex('users', ['email']);
},
async down(api, db) {
// Rollback migration
await db.dropTable('users');
}
}
Migration Helper Methods
The db
object provides these helper methods:
// Table operations
await db.createTable(name, columns);
await db.dropTable(name);
// Column operations
await db.addColumn(table, column, type, options);
await db.dropColumn(table, column);
// Index operations
await db.addIndex(table, columns, options);
await db.dropIndex(table, name);
// Raw SQL
await db.execute(sql, params);
Running Migrations
# Run all pending migrations
npm run migrate:up
# Run up to specific migration
npm run migrate:up -- --target 20240101120000_add_users_table
# Rollback last batch
npm run migrate:down
# Rollback specific number of migrations
npm run migrate:down -- --steps 3
# Check migration status
npm run migrate:status
# Reset (rollback all)
npm run migrate:reset
# Refresh (reset + run all)
npm run migrate:refresh
Programmatic Usage
// Run migrations programmatically
await api.connect();
// Get migration status
const status = await api.migrations.status();
console.log('Applied:', status.applied);
console.log('Pending:', status.pending);
// Run pending migrations
const migrated = await api.migrations.up();
console.log(`Ran ${migrated.length} migrations`);
// Rollback
const rolled = await api.migrations.down(1);
console.log(`Rolled back ${rolled.length} migrations`);
Best Practices
- Test migrations: Always test in development first
- Backup data: Backup production data before running migrations
- Atomic operations: Use transactions when possible
- Down migrations: Always implement rollback logic
- Incremental changes: Make small, focused migrations
- Version control: Commit migrations with related code changes
Example: Adding a Column
// migrations/20240102000000_add_user_profile.js
export default {
async up(api, db) {
// Add columns
await db.addColumn('users', 'bio', 'TEXT');
await db.addColumn('users', 'avatar', 'VARCHAR(500)');
await db.addColumn('users', 'last_login', 'TIMESTAMP');
// Add index for queries
await db.addIndex('users', ['last_login']);
},
async down(api, db) {
await db.dropIndex('users', 'idx_users_last_login');
await db.dropColumn('users', 'last_login');
await db.dropColumn('users', 'avatar');
await db.dropColumn('users', 'bio');
}
}
Hook Reference
Lifecycle Hooks
Hook | When | Context |
---|---|---|
beforeValidate |
Before schema validation | data, options |
afterValidate |
After schema validation | data, options, errors |
beforeGet |
Before fetching single resource | id, options |
afterGet |
After fetching single resource | id, options, result |
beforeQuery |
Before querying resources | params, options |
afterQuery |
After querying resources | params, options, results |
beforeInsert |
Before creating resource | data, options |
afterInsert |
After creating resource | data, options, result |
beforeUpdate |
Before updating resource | id, data, options |
afterUpdate |
After updating resource | id, data, options, result |
beforeDelete |
Before deleting resource | id, options |
afterDelete |
After deleting resource | id, options, result |
transformResult |
Before returning result | options, result |
beforeSend |
Before HTTP response (HTTP only) | options, result |
Query Hooks
Hook | When | Context |
---|---|---|
initializeQuery |
Query builder creation | query, params, options |
modifyQuery |
After initialization | query, params, options |
finalizeQuery |
Before execution | query, params, options |
Hook Context Properties
{
api, // API instance
method, // Operation method
options: {
type, // Resource type
isHttp, // HTTP request flag
isJoinResult, // Joined data flag
joinContext, // Join context ('join')
parentType, // Parent resource type
parentId, // Parent resource ID
parentField // Field name in parent
},
// Method-specific properties
id, // Resource ID
data, // Input data
result, // Operation result
results, // Query results array
params, // Query parameters
errors, // Validation errors
query, // QueryBuilder instance
joinFields // Join metadata
}
Error Classes
All errors extend the base ApiError
class.
ApiError
Base error class.
class ApiError extends Error {
status: number; // HTTP status code
code: string; // Error code
title: string; // Error title
context: any; // Additional context
withContext(context): this;
}
Specific Errors
ValidationError
const error = new ValidationError();
error.addFieldError('email', 'Invalid format', 'INVALID_FORMAT');
throw error;
NotFoundError
throw new NotFoundError('users', 123);
BadRequestError
throw new BadRequestError('Invalid filter parameter')
.withContext({ parameter: 'filter[status]' });
ConflictError
throw new ConflictError('Email already exists')
.withContext({ field: 'email' });
InternalError
throw new InternalError('Database connection failed')
.withContext({ originalError: dbError });
Error Codes
Standard error codes:
VALIDATION_ERROR
- Schema validation failedNOT_FOUND
- Resource not foundBAD_REQUEST
- Invalid requestCONFLICT
- Resource conflictINTERNAL_ERROR
- Server errorDUPLICATE_RESOURCE
- Duplicate keyDATABASE_ERROR
- Database operation failed
Plugins
SimplifiedRecordsPlugin
Transforms JSON:API compliant responses into a simplified format that’s more convenient for developers.
import { SimplifiedRecordsPlugin } from 'json-rest-api';
api.use(SimplifiedRecordsPlugin, {
flattenResponse: false, // Keep data wrapper (default)
includeType: true, // Keep type field (default)
embedRelationships: true // Embed related objects (default)
});
Features:
- Flattened Attributes - Moves attributes directly into the resource object
- Embedded Relationships - Places related objects directly in the response
- Optional Response Flattening - Removes the data wrapper for single resources
- Type Field Control - Optionally exclude the type field
- Developer Convenience - Provides a familiar, intuitive format
Configuration Options:
Option | Type | Default | Description |
---|---|---|---|
flattenResponse |
boolean | false |
Remove data wrapper for single resources |
includeType |
boolean | true |
Include the type field in responses |
embedRelationships |
boolean | true |
Embed related objects instead of using relationships/included |
Transformation Examples:
Single Resource
// Request
GET /api/posts/1
// Without plugin (JSON:API default):
{
"data": {
"id": "1",
"type": "posts",
"attributes": {
"title": "My Post",
"content": "Post content"
},
"relationships": {
"author": {
"data": { "type": "users", "id": "42" }
}
}
},
"included": [{
"id": "42",
"type": "users",
"attributes": {
"name": "John Doe",
"email": "john@example.com"
}
}]
}
// With SimplifiedRecordsPlugin:
{
"data": {
"id": "1",
"type": "posts",
"title": "My Post",
"content": "Post content",
"authorId": "42",
"author": {
"id": "42",
"type": "users",
"name": "John Doe",
"email": "john@example.com"
}
}
}
// With flattenResponse: true
{
"id": "1",
"type": "posts",
"title": "My Post",
"content": "Post content",
"authorId": "42",
"author": {
"id": "42",
"type": "users",
"name": "John Doe",
"email": "john@example.com"
}
}
Collection with Pagination
// With SimplifiedRecordsPlugin + flattenResponse: true
{
"records": [
{
"id": "1",
"type": "posts",
"title": "First Post",
"authorId": "42",
"author": {
"id": "42",
"type": "users",
"name": "John Doe"
}
},
{
"id": "2",
"type": "posts",
"title": "Second Post",
"authorId": "43",
"author": {
"id": "43",
"type": "users",
"name": "Jane Smith"
}
}
],
"meta": {
"totalCount": 10,
"pageNumber": 1,
"pageSize": 2,
"totalPages": 5
},
"links": {
"first": "/api/posts?page[number]=1&page[size]=2",
"last": "/api/posts?page[number]=5&page[size]=2",
"next": "/api/posts?page[number]=2&page[size]=2"
}
}
Performance Considerations:
- Transformation happens after response formatting
- Minimal overhead (~1ms per response)
- No additional database queries
- Works with both programmatic and HTTP APIs
Compatibility:
- Works with all storage plugins
- Compatible with all query features
- Maintains all functionality
-
Requests remain JSON:API formatted
- Resource-specific rules
ViewsPlugin
Provides view-based control over response shapes with smart defaults.
import { ViewsPlugin } from 'json-rest-api/plugins/views.js';
api.use(ViewsPlugin, {
// Global defaults override (optional)
defaults: {
query: { pageSize: 30 },
get: { joins: true }
}
});
Features:
- Smart defaults (no joins for lists, all joins for single records)
- Resource-level default overrides
- Named views for different use cases
- Field filtering
- View-based permissions
Resource Configuration:
api.addResource('posts', postSchema, {
// Optional: Override defaults for this resource
defaults: {
query: {
joins: ['authorId'], // Include author in lists
pageSize: 10,
sort: '-createdAt'
},
get: {
joins: ['authorId', 'categoryId'], // Limit joins for single records
excludeFields: ['internalNotes']
}
},
// Optional: Named views
views: {
minimal: {
query: {
joins: [],
fields: ['id', 'title', 'createdAt']
},
get: {
joins: ['authorId'],
fields: ['id', 'title', 'content', 'authorId']
}
},
admin: {
query: { joins: true },
get: { joins: true, includeFields: ['internalNotes'] }
}
},
// Optional: View permissions
viewPermissions: {
admin: 'admin' // Requires 'admin' role
}
});
Usage:
// Uses smart defaults or resource defaults
GET /api/posts
GET /api/posts/123
// Use named views
GET /api/posts?view=minimal
GET /api/posts/123?view=admin
API Methods:
// Get available views for a resource
const views = api.getResourceViews('posts'); // ['minimal', 'admin']
// Get view configuration
const config = api.getViewConfig('posts', 'minimal', 'query');
QueryLimitsPlugin
Prevents resource exhaustion by limiting query complexity.
import { QueryLimitsPlugin } from 'json-rest-api/plugins/query-limits.js';
api.use(QueryLimitsPlugin, {
maxJoins: 5,
maxJoinDepth: 3,
maxPageSize: 100,
defaultPageSize: 20,
maxFilterFields: 10,
maxSortFields: 3,
maxQueryCost: 100,
// Optional: Resource-specific limits
resources: {
posts: {
maxPageSize: 200,
maxQueryCost: 150
}
},
// Optional: Bypass for certain users
bypassRoles: ['admin'],
bypassCheck: (user) => user?.isPremium
});
Features:
- Limits join count and depth
- Limits page size
- Limits filter and sort complexity
- Cost-based query rejection
- Resource-specific overrides
- Admin/premium user bypass
Error Example:
{
"error": {
"message": "Maximum number of joins (5) exceeded",
"context": {
"joinCount": 7,
"maxJoins": 5,
"joins": ["author", "category", "tags", "comments", "related"]
}
}
}
HTTPPlugin
Adds RESTful JSON:API endpoints to your Express application.
import { HTTPPlugin } from 'json-rest-api';
api.use(HTTPPlugin, {
app: expressApp, // Required: Express app instance
basePath: '/api', // API base path (default: '/api')
strictJsonApi: false, // Enable strict JSON:API compliance (default: false)
// JSON:API Enhancements (new features)
jsonApiVersion: '1.0', // Add version to all responses
jsonApiMetaFormat: true, // Use meta.page format for pagination
includeLinks: true, // Add self/related links to resources
// Per-resource options
typeOptions: {
users: {
searchFields: ['name', 'email'],
allowedMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
}
},
// Content-Type validation
validateContentType: true, // Validate Content-Type header (default: true)
allowedContentTypes: [ // Accepted content types (when not strict)
'application/json',
'application/vnd.api+json'
],
// Middleware
middleware: [], // Global middleware
getUserFromRequest: (req) => req.user, // Extract user from request
// CORS configuration (passed to cors package)
cors: {
origin: '*',
credentials: true
}
});
Features:
- Full JSON:API Compliance - Implements JSON:API specification
- Strict Mode - Optional strict compliance enforcement
- Query Parameters - Support for filtering, sorting, pagination, sparse fieldsets
- Content Negotiation - Validates Content-Type headers
- Error Handling - JSON:API compliant error responses
- Middleware Support - Integrate with Express middleware
Endpoints Created:
Method | Path | Description |
---|---|---|
GET | /api/{type} |
List resources with filtering, sorting, pagination |
GET | /api/{type}/{id} |
Get single resource |
POST | /api/{type} |
Create new resource |
PUT | /api/{type}/{id} |
Replace resource |
PATCH | /api/{type}/{id} |
Update resource |
DELETE | /api/{type}/{id} |
Delete resource |
Strict JSON:API Mode:
When strictJsonApi: true
:
- Content-Type: Only accepts
application/vnd.api+json
POST /api/users Content-Type: application/vnd.api+json ✅ Content-Type: application/json ❌ 415 Error
- Query Parameters: Only standard JSON:API parameters allowed
- ✅
include
,fields
,sort
,page
,filter
,view
- ❌ Legacy:
pageSize
,joins
, direct filters
- ✅
- Examples:
// Valid in strict mode: GET /api/users?filter[name]=John&page[size]=10 GET /api/posts?include=author&fields[posts]=title,content // Invalid in strict mode (400 error): GET /api/users?name=John // Direct filter GET /api/users?pageSize=10 // Legacy pagination GET /api/users?unknownParam=x // Unknown parameter
Query Parameter Support:
Parameter | Format | Example |
---|---|---|
include |
Comma-separated relationships | ?include=author,comments |
fields |
Sparse fieldsets by type | ?fields[posts]=title,content |
sort |
Comma-separated, - for DESC |
?sort=-createdAt,title |
page |
Pagination with size/number | ?page[size]=10&page[number]=2 |
filter |
Field-based filtering | ?filter[status]=published |
Advanced JSON:API Features:
- JSON:API Version Declaration
api.use(HTTPPlugin, { jsonApiVersion: '1.0' }); // All responses include: { "jsonapi": { "version": "1.0" }, "data": { /* ... */ } }
- Meta Field Naming Format
api.use(HTTPPlugin, { jsonApiMetaFormat: true }); // Pagination meta follows JSON:API convention: { "data": [ /* ... */ ], "meta": { "page": { "total": 100, "size": 10, "number": 2, "totalPages": 10 } } }
- Enhanced Error Format
- Errors include
source
field pointing to the problematic field - Support for
source.pointer
(JSON Pointer) andsource.parameter
{ "errors": [{ "status": "422", "code": "VALIDATION_ERROR", "title": "Validation Error", "detail": "Name must be at least 3 characters", "source": { "pointer": "/data/attributes/name" } }] }
- Errors include
- Self and Related Links
api.use(HTTPPlugin, { includeLinks: true }); // Resources include links: { "data": { "type": "posts", "id": "1", "attributes": { /* ... */ }, "relationships": { "author": { "data": { "type": "users", "id": "42" }, "links": { "self": "http://api.example.com/api/posts/1/relationships/author", "related": "http://api.example.com/api/posts/1/author" } } }, "links": { "self": "http://api.example.com/api/posts/1" } } }
- Sorting on Relationship Fields
// Define searchable fields including relationships api.addResource('posts', postSchema, { searchableFields: { 'author.name': 'authorId.name', // Sort by author's name 'category.title': 'categoryId.title' // Sort by category title } }); // Now you can sort by relationship fields: GET /api/posts?sort=author.name,-category.title
Advanced Filter Operators:
The library now supports additional filter operators beyond basic equality:
Operator | SQL Equivalent | Example | Description |
---|---|---|---|
eq |
= |
?filter[age][eq]=25 |
Equal to (default) |
ne |
!= |
?filter[status][ne]=draft |
Not equal to |
gt |
> |
?filter[price][gt]=100 |
Greater than |
gte |
>= |
?filter[age][gte]=18 |
Greater than or equal |
lt |
< |
?filter[stock][lt]=10 |
Less than |
lte |
<= |
?filter[price][lte]=50 |
Less than or equal |
in |
IN |
?filter[status][in]=active,pending |
In array |
nin |
NOT IN |
?filter[role][nin]=admin,root |
Not in array |
like |
LIKE |
?filter[name][like]=%john% |
Pattern match |
ilike |
ILIKE |
?filter[email][ilike]=%@EXAMPLE.COM |
Case-insensitive pattern |
notlike |
NOT LIKE |
?filter[path][notlike]=/admin/% |
Not matching pattern |
startsWith |
LIKE x% |
?filter[name][startsWith]=John |
Starts with |
endsWith |
LIKE %x |
?filter[email][endsWith]=.com |
Ends with |
contains |
LIKE %x% |
?filter[bio][contains]=developer |
Contains substring |
icontains |
ILIKE %x% |
?filter[title][icontains]=NEWS |
Case-insensitive contains |
between |
BETWEEN |
?filter[age][between]=18,65 |
Between two values |
null |
IS NULL |
?filter[deletedAt][null]=true |
Is null |
notnull |
IS NOT NULL |
?filter[email][notnull]=true |
Is not null |
LoggingPlugin
Implements structured logging with security best practices.
import { LoggingPlugin } from 'json-rest-api/plugins/logging.js';
api.use(LoggingPlugin, {
level: 'info', // 'error', 'warn', 'info', 'debug'
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.
auditLog: true // Enable audit logging for create/update/delete
});
Features:
- Structured JSON logging
- Request/response logging
- SQL query logging
- Performance timing
- Sensitive data redaction
- Audit logging for changes
- Custom logger support
Log Methods:
api.log.error('Database error', { code: 'DB_001', details: error });
api.log.warn('Validation warning', { field: 'email' });
api.log.info('User login', { userId: user.id });
api.log.debug('Query executed', { sql, duration: 123 });
OpenAPIPlugin
Generates OpenAPI 3.0 specification and serves Swagger UI.
import { OpenAPIPlugin } from 'json-rest-api/plugins/openapi.js';
api.use(OpenAPIPlugin, {
title: 'My API',
version: '1.0.0',
description: 'REST API with JSON:API specification',
servers: [
{ url: 'http://localhost:3000/api' },
{ url: 'https://api.example.com' }
],
contact: {
name: 'API Support',
email: 'support@example.com'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
});
Endpoints:
GET /openapi.json
- OpenAPI specificationGET /docs
- Swagger UI interface
Features:
- Auto-generates API documentation from schemas
- JSON:API compliant paths
- Security scheme definitions
- Request/response examples
- Interactive Swagger UI
SecurityPlugin
Implements comprehensive security features.
import { SecurityPlugin } from 'json-rest-api/plugins/security.js';
api.use(SecurityPlugin, {
rateLimit: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP'
},
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https:']
}
},
authentication: {
type: 'bearer', // 'bearer', 'basic', 'apikey'
header: 'Authorization',
queryParam: 'api_key',
required: true
},
publicRead: false,
allowUnknownFilters: false,
verifyToken: async (token, context) => {
// Custom token verification
return await verifyJWT(token);
}
});
Features:
- Rate limiting per IP
- Security headers (CSP, X-Frame-Options, etc.)
- Authentication middleware
- Input sanitization
- SQL injection protection
- Request ID tracking
- XSS prevention
Security Headers Added:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Strict-Transport-Security
Content-Security-Policy
X-Request-ID
VersioningPlugin
Manages API versioning and resource version control.
import { VersioningPlugin } from 'json-rest-api/plugins/versioning.js';
api.use(VersioningPlugin, {
// API versioning
apiVersion: '2.0.0',
versionHeader: 'api-version',
versionParam: 'v',
strict: false, // Allow version mismatch
// Resource versioning
versionField: 'version',
lastModifiedField: 'lastModified',
modifiedByField: 'modifiedBy',
optimisticLocking: true,
trackHistory: true,
historyTable: 'posts_history'
});
Features:
- API version negotiation
- Resource version tracking
- Optimistic locking
- Version history
- Version comparison/diffing
- Version restoration
Methods:
// Compare versions
api.compareVersions('1.2.3', '1.2.4'); // returns -1
// Get version history
const history = await api.getVersionHistory('posts', postId);
// Restore specific version
await api.restoreVersion('posts', postId, 3);
// Diff between versions
const diff = await api.diffVersions('posts', postId, 3, 5);
Optimistic Locking:
// Update with version check
await api.update('posts', postId, {
title: 'New Title',
version: 3 // Must match current version
});
// Throws 409 Conflict if version mismatch
SQLPlugin
Generic SQL implementation that works with any database adapter.
import { SQLPlugin } from 'json-rest-api/plugins/sql-generic.js';
// This plugin is automatically used when you install a database adapter
// No explicit configuration needed
api.use(SQLPlugin);
Features:
- Works with MySQLAdapter or AlaSQLAdapter
- Smart query building
- JSON field handling
- Array field searching
- Join support
- Automatic table creation
- Transaction support
Database Adapters:
- MySQLAdapter: Production MySQL/MariaDB support
- AlaSQLAdapter: In-memory SQL for development/testing
Database Adapters
Database adapters provide the low-level implementation for storage plugins. They implement the db.*
interface that plugins use.
AlaSQLAdapter
Powers the MemoryPlugin with in-memory SQL database functionality.
import { AlaSQLAdapter } from 'json-rest-api/plugins/adapters/alasql-adapter.js';
// Usually not used directly - MemoryPlugin handles this
const adapter = new AlaSQLAdapter();
api.use(adapter);
Features:
- Full SQL support via AlaSQL library
- In-memory storage (no persistence)
- Automatic table creation
- JSON field support
- Basic transaction simulation
- Perfect for testing and development
MySQLAdapter
Powers the MySQLPlugin with production MySQL/MariaDB support.
import { MySQLAdapter } from 'json-rest-api/plugins/adapters/mysql-adapter.js';
// Usually not used directly - MySQLPlugin handles this
const adapter = new MySQLAdapter({
connection: {
host: 'localhost',
user: 'root',
password: 'password',
database: 'myapp'
}
});
api.use(adapter);
Features:
- Connection pooling
- Real transactions
- Prepared statements
- JSON column support
- Automatic schema sync
- Production-ready performance
Adapter vs Plugin:
- Adapters implement the database interface (
db.query
,db.connect
, etc.) - Plugins (MemoryPlugin, MySQLPlugin) use adapters and add convenience features
- SQLPlugin provides the common SQL logic that works with any adapter
ComputedPlugin
Provides a way to create API resources that generate data on-the-fly without any database storage. Perfect for computed/derived data, external API proxies, real-time calculations, and mock data generation.
import { ComputedPlugin } from 'json-rest-api/plugins/computed.js';
api.use(ComputedPlugin);
// Mix computed resources with database-backed ones
api.addResource('users', userSchema); // Regular DB resource
// Computed resource - generates data on the fly
api.addResource('user-stats', statsSchema, {
compute: {
get: async (id, context) => {
// Access other resources
const user = await context.api.resources.users.get(id);
const posts = await context.api.resources.posts.query({
filter: { userId: id }
});
// Return computed data
return {
id,
username: user.data.attributes.name,
postCount: posts.data.length,
avgPostLength: posts.data.reduce((sum, p) =>
sum + p.attributes.content.length, 0) / posts.data.length
};
},
query: async (params, context) => {
// Generate multiple items
// Plugin handles filtering, sorting, pagination automatically!
return generateData();
}
}
});
// External API proxy example
api.addResource('weather', weatherSchema, {
compute: {
query: async (params, context) => {
const response = await fetch('https://api.weather.com/...');
const data = await response.json();
// Transform and return - all API features work!
return data.map(item => ({
id: item.city,
temperature: item.temp,
conditions: item.weather
}));
},
// Tell plugin you handle filtering for performance
handlesFiltering: true,
handlesSorting: true,
handlesPagination: true
}
});
Features:
- Mix computed and database resources in same API
- Full API feature support (validation, auth, hooks, filtering, etc.)
- Automatic filtering/sorting/pagination of results
- Can access other resources via context.api
- Support for all CRUD operations (if implemented)
- Perfect for aggregations, external APIs, real-time data
Compute Options:
get(id, context)
- Get single computed itemquery(params, context)
- Query multiple computed itemsinsert(data, context)
- Create computed item (optional)update(id, data, context)
- Update computed item (optional)delete(id, context)
- Delete computed item (optional)handlesFiltering
- True if compute function handles filteringhandlesSorting
- True if compute function handles sortinghandlesPagination
- True if compute function handles pagination
CQRSPlugin
Implements Command Query Responsibility Segregation (CQRS) pattern, separating reads and writes into different models, handlers, or even databases. Includes support for Event Sourcing, Projections, and Sagas.
import { CQRSPlugin, Command, Query, Event } from 'json-rest-api';
api.use(CQRSPlugin, {
eventStore: false, // Enable event sourcing
projections: false, // Enable projections for read models
sagas: false, // Enable sagas for complex workflows
separateDatabases: false, // Use different databases for read/write
// Only if separateDatabases is true
writeDatabase: {
plugin: 'mysql',
options: { /* connection options */ }
},
readDatabase: {
plugin: 'memory', // Can be different type
options: { /* connection options */ }
}
});
Features:
- Command/Query Separation - Different handlers for reads and writes
- Event Sourcing - Store all changes as events
- Projections - Build optimized read models from events
- Sagas - Orchestrate complex multi-step workflows
- Separate Databases - Different datastores for read/write sides
- Auto-Generated Handlers - CRUD operations as commands/queries
Command and Query Definition
Defining Commands (Writes)
// Simple command handler
api.command('CreateOrder', async (command) => {
const { customerId, items } = command.data;
// Validation and business logic
if (!items || items.length === 0) {
throw new Error('Order must have items');
}
// Execute write operation
const order = await api.resources.orders.create({
customerId,
items,
total: calculateTotal(items),
status: 'pending'
});
// Optionally emit domain events
await api.emitDomainEvent(new Event('OrderPlaced', order, order.data.id));
return order;
});
// Command handler with event sourcing
api.command('ShipOrder', async (command) => {
const { orderId, carrier, trackingNumber } = command.data;
// Load current state
const order = await api.resources.orders.get(orderId);
// Business rule validation
if (order.data.attributes.status !== 'paid') {
throw new Error('Can only ship paid orders');
}
// Update state
const result = await api.resources.orders.update(orderId, {
status: 'shipped',
shippedAt: new Date(),
carrier,
trackingNumber
});
// Emit event for event store and projections
await api.emitDomainEvent(new Event(
'OrderShipped',
{ orderId, carrier, trackingNumber },
orderId
));
return result;
});
Defining Queries (Reads)
// Simple query handler
api.query('GetOrdersByCustomer', async (query) => {
const { customerId, status, limit = 10 } = query.criteria;
const filter = { customerId };
if (status) filter.status = status;
return await api.resources.orders.query({
filter,
page: { size: limit },
sort: [{ field: 'createdAt', direction: 'DESC' }]
});
});
// Query with complex aggregation
api.query('GetCustomerStats', async (query) => {
const { customerId, dateRange } = query.criteria;
// Could query a read-optimized view or projection
const orders = await api.resources.orders.query({
filter: {
customerId,
createdAt: { between: dateRange }
}
});
// Calculate statistics
const stats = {
totalOrders: orders.data.length,
totalSpent: orders.data.reduce((sum, order) =>
sum + order.attributes.total, 0
),
averageOrderValue: orders.data.length > 0
? this.totalSpent / orders.data.length
: 0
};
return stats;
});
Executing Commands and Queries
// Using Command class
const createCommand = new Command({
customerId: '123',
items: [
{ productId: 'abc', quantity: 2, price: 29.99 }
]
});
createCommand.constructor.name = 'CreateOrder';
const order = await api.execute(createCommand);
// Using Query class
const statsQuery = new Query({
customerId: '123',
dateRange: ['2024-01-01', '2024-12-31']
});
statsQuery.constructor.name = 'GetCustomerStats';
const stats = await api.execute(statsQuery);
// Alternative: Direct execution (when you have the handler name)
const result = await api._cqrs.commandBus.execute({
constructor: { name: 'CreateOrder' },
data: { customerId: '123', items: [...] }
});
Auto-Generated CRUD Commands and Queries
For each resource, the plugin automatically generates standard CRUD operations:
// Commands (writes) - generated pattern: {Action}{Resource}
// These are automatically created when you use addResource()
// Create command
const createCmd = new Command({ name: 'John', email: 'john@example.com' });
createCmd.constructor.name = 'CreateUsers';
await api.execute(createCmd);
// Update command
const updateCmd = new Command({ id: 123, data: { name: 'Jane' } });
updateCmd.constructor.name = 'UpdateUsers';
await api.execute(updateCmd);
// Delete command
const deleteCmd = new Command({ id: 123 });
deleteCmd.constructor.name = 'DeleteUsers';
await api.execute(deleteCmd);
// Queries (reads) - generated patterns
// Get by ID
const getQuery = new Query({ id: 123 });
getQuery.constructor.name = 'GetUsersById';
await api.execute(getQuery);
// List/search
const listQuery = new Query({ filter: { active: true }, page: { size: 20 } });
listQuery.constructor.name = 'ListUsers';
await api.execute(listQuery);
Event Sourcing
When eventStore: true
, all domain events are stored and can be replayed:
// Emit domain events
await api.emitDomainEvent(new Event(
'ProductPriceChanged', // Event type
{ oldPrice: 99, newPrice: 79, reason: 'Sale' }, // Event data
productId // Aggregate ID
));
// Subscribe to domain events
api.onDomainEvent('ProductPriceChanged', async (event) => {
console.log(`Price changed for product ${event.aggregateId}`);
// Update search index, send notifications, etc.
});
// Subscribe to all events
api.onDomainEvent('*', async (event) => {
console.log(`Event: ${event.type} on ${event.aggregateId}`);
});
// Access event store directly
const eventStore = api.getEventStore();
// Get all events for an aggregate
const events = await eventStore.getEvents(aggregateId, fromVersion);
// Get all events (for rebuilding projections)
const allEvents = await eventStore.getAllEvents(fromTimestamp);
// Save snapshot for performance
await eventStore.saveSnapshot(aggregateId, currentState, version);
const snapshot = await eventStore.getSnapshot(aggregateId);
Projections
Build read-optimized views from events:
// Define a projection
const ordersByCustomerProjection = {
// Which events this projection handles
handles: ['OrderCreated', 'OrderCancelled'],
// Internal state
ordersByCustomer: new Map(),
// Handle each event
async handle(event) {
switch (event.type) {
case 'OrderCreated':
const customerId = event.data.attributes.customerId;
if (!this.ordersByCustomer.has(customerId)) {
this.ordersByCustomer.set(customerId, []);
}
this.ordersByCustomer.get(customerId).push({
orderId: event.aggregateId,
total: event.data.attributes.total,
createdAt: event.timestamp
});
break;
case 'OrderCancelled':
// Remove from projection
for (const [customerId, orders] of this.ordersByCustomer) {
const index = orders.findIndex(o => o.orderId === event.aggregateId);
if (index >= 0) {
orders.splice(index, 1);
break;
}
}
break;
}
},
// Reset projection (for rebuilds)
async reset() {
this.ordersByCustomer.clear();
},
// Query methods
getOrdersForCustomer(customerId) {
return this.ordersByCustomer.get(customerId) || [];
}
};
// Register projection
api.projection('ordersByCustomer', ordersByCustomerProjection);
// Rebuild projection from all events
await api._cqrs.projectionManager.rebuild('ordersByCustomer', eventStore);
// Use projection in queries
api.query('GetCustomerOrderHistory', async (query) => {
const projection = api._cqrs.projectionManager.projections.get('ordersByCustomer');
return projection.getOrdersForCustomer(query.criteria.customerId);
});
Sagas
Orchestrate complex business processes:
// Define a saga
class OrderFulfillmentSaga {
constructor() {
this.state = {
orderId: null,
paymentId: null,
shipmentId: null,
status: 'started'
};
}
// Events that start this saga
get startsWith() {
return ['OrderCreated'];
}
// All events this saga handles
get handles() {
return ['OrderCreated', 'PaymentProcessed', 'PaymentFailed',
'InventoryReserved', 'InventoryUnavailable', 'OrderShipped'];
}
// Handle events and orchestrate process
async handle(event) {
switch (event.type) {
case 'OrderCreated':
this.state.orderId = event.aggregateId;
// Initiate payment
const payment = await api.resources.payments.create({
orderId: this.state.orderId,
amount: event.data.attributes.total
});
this.state.paymentId = payment.data.id;
break;
case 'PaymentProcessed':
// Reserve inventory
await api.resources.inventory.reserve({
orderId: this.state.orderId,
items: this.state.items
});
break;
case 'PaymentFailed':
// Compensate - cancel order
await api.resources.orders.update(this.state.orderId, {
status: 'cancelled',
reason: 'Payment failed'
});
this.state.status = 'failed';
break;
case 'InventoryReserved':
// Create shipment
const shipment = await api.resources.shipments.create({
orderId: this.state.orderId
});
this.state.shipmentId = shipment.data.id;
break;
case 'OrderShipped':
// Complete the saga
await api.resources.orders.update(this.state.orderId, {
status: 'completed'
});
this.state.status = 'completed';
break;
}
}
// Check if saga is complete
isComplete() {
return ['completed', 'failed'].includes(this.state.status);
}
}
// Register saga
api.saga('OrderFulfillment', OrderFulfillmentSaga);
Separate Read/Write Databases
Use different databases optimized for their workload:
api.use(CQRSPlugin, {
separateDatabases: true,
writeDatabase: {
plugin: 'mysql', // ACID compliant for writes
options: {
host: 'write-db.example.com',
database: 'myapp_write'
}
},
readDatabase: {
plugin: 'memory', // Fast in-memory for reads
options: {}
},
eventStore: true // Sync via events
});
// Commands automatically use write database
api.command('UpdateProduct', async (command) => {
// This uses api._writeApi internally
return await api._writeApi.resources.products.update(
command.data.id,
command.data.updates
);
});
// Queries automatically use read database
api.query('SearchProducts', async (query) => {
// This uses api._readApi internally
return await api._readApi.resources.products.query({
filter: query.criteria
});
});
// Automatic synchronization via events
// When writeDatabase updates, events sync to readDatabase
Advanced Usage
Custom Event Store Implementation
class MongoEventStore {
constructor(mongoClient) {
this.events = mongoClient.collection('events');
}
async append(event) {
await this.events.insertOne(event);
return event;
}
async getEvents(aggregateId, fromVersion = 0) {
return await this.events
.find({ aggregateId, version: { $gte: fromVersion } })
.sort({ version: 1 })
.toArray();
}
}
// Replace default event store
api._cqrs.eventStore = new MongoEventStore(mongoClient);
Command Validation
api.command('TransferMoney', async (command) => {
const { fromAccount, toAccount, amount } = command.data;
// Validate command
if (amount <= 0) {
throw new BadRequestError('Amount must be positive');
}
if (fromAccount === toAccount) {
throw new BadRequestError('Cannot transfer to same account');
}
// Check business rules
const source = await api.resources.accounts.get(fromAccount);
if (source.data.attributes.balance < amount) {
throw new BadRequestError('Insufficient funds');
}
// Execute in transaction if available
await api.transaction(async (trx) => {
await trx.resources.accounts.update(fromAccount, {
balance: source.data.attributes.balance - amount
});
await trx.resources.accounts.update(toAccount, {
balance: { increment: amount } // If supported
});
});
// Emit event
await api.emitDomainEvent(new Event('MoneyTransferred', {
fromAccount,
toAccount,
amount,
timestamp: Date.now()
}));
});
Testing CQRS Code
// Test commands
describe('CreateOrder command', () => {
it('should create order and emit event', async () => {
const events = [];
api.onDomainEvent('*', (event) => events.push(event));
const command = new Command({
customerId: '123',
items: [{ productId: 'abc', quantity: 1 }]
});
command.constructor.name = 'CreateOrder';
const result = await api.execute(command);
expect(result.data.attributes.status).toBe('pending');
expect(events).toHaveLength(1);
expect(events[0].type).toBe('OrderCreated');
});
});
// Test projections
describe('OrderStats projection', () => {
it('should calculate stats correctly', async () => {
const projection = createOrderStatsProjection();
await projection.handle(new Event('OrderCreated', {
attributes: { total: 100, customerId: '123' }
}));
await projection.handle(new Event('OrderCreated', {
attributes: { total: 200, customerId: '123' }
}));
const stats = projection.getCustomerStats('123');
expect(stats.totalOrders).toBe(2);
expect(stats.totalRevenue).toBe(300);
});
});
CQRS Best Practices
- Keep Commands Task-Oriented
// Good: Task-focused command api.command('ActivateUser', handler); // Bad: Generic CRUD command api.command('UpdateUser', handler);
- Make Commands Idempotent
api.command('ProcessPayment', async (command) => { const { paymentId } = command.data; // Check if already processed const existing = await api.resources.payments.get(paymentId); if (existing.data.attributes.status === 'processed') { return existing; // Idempotent } // Process payment... });
- Design Events for Replaying
// Good: Complete event data new Event('OrderShipped', { orderId, shippedAt: Date.now(), carrier: 'FedEx', trackingNumber: '123456', items: [...], // Include all relevant data }); // Bad: Minimal event new Event('OrderShipped', { orderId });
- Use Projections for Complex Queries
// Instead of complex joins, maintain a projection api.projection('productSalesRanking', { handles: ['OrderCreated'], rankings: new Map(), async handle(event) { // Update rankings based on order data }, getTopProducts(limit = 10) { return Array.from(this.rankings.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, limit); } });
- Handle Eventual Consistency
api.query('GetOrder', async (query) => { const { orderId, consistency = 'eventual' } = query.criteria; if (consistency === 'strong') { // Query write database directly return await api._writeApi.resources.orders.get(orderId); } else { // Query read database (may be slightly out of date) return await api._readApi.resources.orders.get(orderId); } });
ApiGatewayPlugin
import { ApiGatewayPlugin } from 'json-rest-api/plugins/api-gateway';
Transforms JSON-REST-API into an API gateway/orchestrator. Instead of database-backed resources, create resources that call external APIs with built-in resilience, transformations, and saga orchestration.
Basic Usage
// Enable API Gateway features
api.use(ApiGatewayPlugin, {
enableSagas: true, // Enable saga orchestration
enableMetrics: true, // Track API performance
defaultTimeout: 30000, // 30 second timeout
defaultRetries: 3 // Retry failed requests
});
// Add an API-backed resource
api.addApiResource('users', {
baseUrl: 'https://api.userservice.com',
auth: { type: 'bearer', token: process.env.USER_API_TOKEN },
endpoints: {
get: { path: '/users/:id' },
list: { path: '/users' },
create: { path: '/users', method: 'POST' },
update: { path: '/users/:id', method: 'PUT' },
delete: { path: '/users/:id', method: 'DELETE' }
}
});
// Use it like a normal resource
const user = await api.resources.users.get(123);
const users = await api.resources.users.query({ active: true });
Features
- External API Integration - Call any REST API as a resource
- Request/Response Transformation - Adapt any API format
- Circuit Breakers - Protect against cascading failures
- Automatic Retries - Handle transient failures
- Saga Orchestration - Coordinate multi-service transactions
- Health Monitoring - Track API status and performance
API Resource Configuration
Authentication Types
// Bearer token
api.addApiResource('github', {
baseUrl: 'https://api.github.com',
auth: { type: 'bearer', token: process.env.GITHUB_TOKEN }
});
// API Key
api.addApiResource('weather', {
baseUrl: 'https://api.weather.com',
auth: {
type: 'apiKey',
header: 'X-API-Key',
key: process.env.WEATHER_KEY
}
});
// Basic Auth
api.addApiResource('legacy', {
baseUrl: 'https://old.system.com',
auth: {
type: 'basic',
username: process.env.LEGACY_USER,
password: process.env.LEGACY_PASS
}
});
Request/Response Transformations
api.addApiResource('payments', {
baseUrl: 'https://api.stripe.com/v1',
auth: { type: 'bearer', token: process.env.STRIPE_KEY },
transformers: {
charge: {
// Transform outgoing request
request: (data) => ({
amount: Math.round(data.amount * 100), // Convert to cents
currency: data.currency || 'usd',
source: data.token,
metadata: { orderId: data.orderId }
}),
// Transform incoming response
response: (stripeData) => ({
id: stripeData.id,
amount: stripeData.amount / 100, // Convert back
status: stripeData.status,
created: new Date(stripeData.created * 1000)
})
}
},
endpoints: {
charge: { path: '/charges', method: 'POST' },
refund: { path: '/refunds', method: 'POST' }
}
});
// Use transformed API
const payment = await api.resources.payments.charge({
amount: 99.99, // Dollars, will be converted to cents
token: 'tok_visa',
orderId: 'ORD-123'
});
Circuit Breaker Configuration
api.addApiResource('flaky-service', {
baseUrl: 'https://unreliable.api.com',
// Circuit breaker settings
circuitBreaker: {
failureThreshold: 5, // Open after 5 failures
resetTimeout: 60000, // Try again after 1 minute
monitoringPeriod: 10000 // Within 10 second window
},
timeout: 5000, // 5 second timeout
retries: 2, // Retry twice on failure
retryDelay: 1000 // 1 second between retries
});
// Circuit breaker states:
// CLOSED: Normal operation
// OPEN: Rejecting all requests (fail fast)
// HALF_OPEN: Testing if service recovered
Saga Orchestration
Sagas coordinate complex workflows across multiple services with automatic rollback on failure:
api.saga('CheckoutSaga', {
startsWith: 'CheckoutStarted', // Triggering event
async handle(event, { executeStep, compensate, emit }) {
const { orderId, customerId, items, paymentToken } = event.data;
try {
// Step 1: Reserve inventory
const reservation = await executeStep('reserveInventory',
// Action
async () => {
return await api.resources.inventory.reserve({
items,
orderId
});
},
// Compensation (rollback)
async () => {
await api.resources.inventory.cancel(reservation.id);
}
);
// Step 2: Process payment
const payment = await executeStep('processPayment',
async () => {
return await api.resources.payments.charge({
amount: calculateTotal(items),
token: paymentToken,
orderId
});
},
async () => {
await api.resources.payments.refund(payment.id);
}
);
// Step 3: Create shipment
const shipment = await executeStep('createShipment',
async () => {
return await api.resources.shipping.create({
orderId,
items
});
},
async () => {
await api.resources.shipping.cancel(shipment.id);
}
);
// Success - confirm everything
await api.resources.inventory.confirm(reservation.id);
await emit('CheckoutCompleted', { orderId });
} catch (error) {
// Automatic rollback of completed steps
await compensate();
await emit('CheckoutFailed', { orderId, error: error.message });
}
}
});
// Trigger the saga
await api.emitEvent('CheckoutStarted', {
orderId: 'ORD-123',
customerId: 'CUST-456',
items: [{ sku: 'WIDGET-1', quantity: 2 }],
paymentToken: 'tok_visa'
});
Health Monitoring
// Get API health status
const health = api.getApiHealth();
console.log(health);
// {
// users: {
// url: 'https://api.users.com',
// circuit: { state: 'CLOSED', failures: 0 },
// metrics: {
// requests: 1543,
// errors: 12,
// avgResponseTime: 234
// }
// },
// payments: {
// url: 'https://api.stripe.com',
// circuit: { state: 'OPEN', failures: 5 },
// metrics: {
// requests: 89,
// errors: 5,
// avgResponseTime: 567
// }
// },
// sagas: {
// active: [
// { id: 'abc123', name: 'CheckoutSaga', state: 'RUNNING' }
// ]
// }
// }
Batch Operations
// Execute multiple API calls
const results = await api.batchApiCalls([
{ resource: 'users', method: 'get', data: 1 },
{ resource: 'orders', method: 'query', data: { userId: 1 } },
{ resource: 'reviews', method: 'query', data: { userId: 1 } }
]);
// With transaction semantics (rollback on failure)
const results = await api.batchApiCalls([
{ resource: 'users', method: 'create', data: userData },
{ resource: 'accounts', method: 'create', data: accountData },
{ resource: 'profile', method: 'create', data: profileData }
], { transactional: true });
Custom Methods
api.addApiResource('orders', {
baseUrl: 'https://api.orders.com',
endpoints: {
// Standard CRUD
get: { path: '/orders/:id' },
create: { path: '/orders', method: 'POST' },
// Custom methods
ship: { path: '/orders/:id/ship', method: 'POST' },
cancel: { path: '/orders/:id/cancel', method: 'POST' },
getInvoice: { path: '/orders/:id/invoice' }
},
methods: {
ship: { path: '/orders/:id/ship', method: 'POST' },
cancel: { path: '/orders/:id/cancel', method: 'POST' },
getInvoice: { path: '/orders/:id/invoice' }
}
});
// Use custom methods
await api.resources.orders.ship({ id: orderId, carrier: 'ups' });
const invoice = await api.resources.orders.getInvoice({ id: orderId });
Advanced Configuration
// Configure API after creation
api.configureApi('payments', {
// Add or update transformers
transformers: {
list: {
request: (params) => ({
limit: params.pageSize || 10,
starting_after: params.cursor
}),
response: (data) => ({
items: data.data,
hasMore: data.has_more,
nextCursor: data.data[data.data.length - 1]?.id
})
}
},
// Add custom headers
headers: {
'X-Custom-Header': 'value'
}
});
API Gateway Best Practices
- Use Environment Variables for Configuration
api.addApiResource('service', { baseUrl: process.env.SERVICE_URL, auth: { type: 'bearer', token: process.env.SERVICE_TOKEN } });
- Implement Proper Error Handling
try { const result = await api.resources.payments.charge(data); } catch (error) { if (error.status === 402) { // Payment failed await handlePaymentFailure(error); } else if (error.code === 'ETIMEDOUT') { // Timeout - maybe retry later await queueForRetry(data); } else { // Unknown error throw error; } }
- Monitor Circuit Breaker States
setInterval(() => { const health = api.getApiHealth(); for (const [service, status] of Object.entries(health)) { if (status.circuit.state === 'OPEN') { alertOps(`Circuit breaker OPEN for ${service}`); } } }, 30000); // Check every 30 seconds
- Use Sagas for Complex Workflows
// Don't manually orchestrate // Use sagas for automatic rollback and state management api.saga('ComplexWorkflow', { async handle(event, { executeStep, compensate }) { // Saga handles failures and rollbacks automatically } });
- Transform APIs to Your Domain
// Transform external API responses to match your domain model transformers: { get: { response: (externalUser) => ({ id: externalUser.user_id, name: externalUser.full_name, email: externalUser.email_address, // Map to your expected format }) } }
DDDPlugin
import { DDDPlugin } from 'json-rest-api/plugins/ddd';
Provides Domain-Driven Design support with rails and structure for implementing DDD correctly. Includes base classes for value objects, entities, aggregates, repositories, and domain services.
Basic Usage
// Enable DDD support
api.use(DDDPlugin, {
logEvents: true // Log domain events for debugging
});
// Define value objects
class Money extends api.ValueObject {
constructor({ amount, currency }) {
if (amount < 0) throw new Error('Money cannot be negative');
super({ amount, currency });
}
add(other) {
if (this.currency !== other.currency) {
throw new Error('Cannot add different currencies');
}
return new Money({
amount: this.amount + other.amount,
currency: this.currency
});
}
}
// Define aggregates
class Order extends api.Aggregate {
static get schema() {
return {
customerId: { type: 'id', required: true },
items: { type: 'array', default: [] },
total: { type: 'value', valueObject: Money },
status: { type: 'string', default: 'pending' }
};
}
addItem(productId, price, quantity) {
this.enforceInvariant(
this.status === 'pending',
'Can only add items to pending orders'
);
this.items.push({ productId, price, quantity });
this.recalculateTotal();
this.recordEvent('ItemAddedToOrder', {
orderId: this.id,
productId,
quantity
});
}
}
// Define repositories
class OrderRepository extends api.Repository {
constructor() {
super('orders', Order);
}
async findByCustomer(customerId) {
return await this.query({ customerId });
}
}
// Define bounded context
api.boundedContext('sales', {
aggregates: [Order, Customer],
repositories: [OrderRepository, CustomerRepository],
services: [PricingService]
});
Features
- Value Objects - Immutable objects defined by their values
- Entities - Objects with persistent identity
- Aggregates - Consistency boundaries with invariant enforcement
- Repositories - Abstract data access
- Domain Services - Cross-aggregate business logic
- Bounded Contexts - Clear boundaries between domains
- Domain Events - Capture important business occurrences
- Specifications - Encapsulate business rules
Value Objects
// Immutable, compared by value
class Email extends api.ValueObject {
constructor(value) {
if (!value || !value.includes('@')) {
throw new Error('Invalid email');
}
super({ value });
}
getDomain() {
return this.value.split('@')[1];
}
}
// Usage
const email1 = new Email('john@example.com');
const email2 = new Email('john@example.com');
console.log(email1.equals(email2)); // true - same value
// Immutable - create new instance for changes
const newEmail = email1.with({ value: 'jane@example.com' });
Aggregates
class ShoppingCart extends api.Aggregate {
static get schema() {
return {
customerId: { type: 'id', required: true },
items: { type: 'array', default: [] },
status: { type: 'string', default: 'active' }
};
}
addItem(productId, quantity) {
// Enforce business rules
this.enforceInvariant(
this.status === 'active',
'Cannot modify inactive cart'
);
this.enforceInvariant(
quantity > 0,
'Quantity must be positive'
);
this.enforceInvariant(
this.items.length < 50,
'Cart cannot exceed 50 items'
);
// Update state
this.items.push({ productId, quantity });
// Record domain event
this.recordEvent('ItemAddedToCart', {
cartId: this.id,
productId,
quantity
});
}
checkout() {
this.enforceInvariant(
this.items.length > 0,
'Cannot checkout empty cart'
);
this.status = 'checkedOut';
this.recordEvent('CartCheckedOut', {
cartId: this.id,
customerId: this.customerId
});
}
}
Repositories
class CustomerRepository extends api.Repository {
constructor() {
super('customers', Customer); // resource name, aggregate class
}
// Custom query methods
async findByEmail(email) {
const results = await this.query({ email });
return results[0] || null;
}
async findPremiumCustomers() {
return await this.query({
creditLimit: { gte: 10000 },
status: 'active'
});
}
}
// Usage
const repo = api.getRepository('CustomerRepository');
const customer = await repo.findByEmail('john@example.com');
await repo.save(customer);
Domain Services
class PricingService extends api.DomainService {
calculateDiscount(customer, order) {
let discount = 0;
// Premium customers get 10% off
if (customer.isPremium()) {
discount += 0.10;
}
// Bulk orders get 5% off
if (order.itemCount > 10) {
discount += 0.05;
}
return Math.min(discount, 0.15); // Max 15%
}
}
// Usage
const service = api.getService('PricingService');
const discount = service.calculateDiscount(customer, order);
Bounded Contexts
// Define separate contexts for different domains
api.boundedContext('sales', {
aggregates: [Order, Customer, Product],
repositories: [OrderRepository, CustomerRepository],
services: [PricingService]
});
api.boundedContext('inventory', {
aggregates: [InventoryItem, Warehouse],
repositories: [InventoryRepository],
services: [StockService]
});
// Get context components
const salesContext = api.getContext('sales');
const orderRepo = api.getRepository('OrderRepository', 'sales');
Domain Events
// Define events
const OrderPlaced = api.domainEvent('OrderPlaced', {
orderId: true, // required fields
customerId: true,
total: true
});
// Listen to events
api.onDomainEvent('OrderPlaced', async (event) => {
console.log('New order:', event.data.orderId);
// Send confirmation email
// Update inventory
// Notify warehouse
});
// Events from aggregates are auto-published when saved
const order = new Order();
order.place(customerId, items);
await orderRepo.save(order); // 'OrderPlaced' event emitted
// Manual event emission
await api.emitDomainEvent('SystemMaintenance', {
scheduledFor: '2024-01-15T02:00:00Z'
});
Specifications
// Define business rules as specifications
class PremiumCustomerSpec extends api.Specification {
isSatisfiedBy(customer) {
return customer.creditLimit >= 10000 ||
customer.loyaltyYears >= 5;
}
toQuery() {
return {
$or: [
{ creditLimit: { gte: 10000 } },
{ loyaltyYears: { gte: 5 } }
]
};
}
}
// Combine specifications
const premiumSpec = new PremiumCustomerSpec();
const activeSpec = api.specification('Active',
customer => customer.status === 'active',
() => ({ status: 'active' })
);
const eligibleCustomers = premiumSpec.and(activeSpec);
// Use for queries
const customers = await customerRepo.findBySpec(eligibleCustomers);
DDD Best Practices
- Keep Aggregates Small
// Good: Focused aggregate class Order extends Aggregate { customerId: string; // Reference, not embedded items: OrderItem[]; // Part of aggregate }
- Use Value Objects for Domain Concepts
// Bad: Primitive obsession class Product { price: number; currency: string; } // Good: Rich value object class Product { price: Money; }
- Make Implicit Concepts Explicit
// Bad: Hidden business rule if (order.total > 1000 && customer.joinDate < lastYear) { discount = 0.1; } // Good: Named concept const loyaltyDiscount = new LoyaltyDiscount(); if (loyaltyDiscount.isEligible(customer, order)) { discount = loyaltyDiscount.calculate(); }
- Domain Events Should Be Past Tense
// Bad: Commands 'CreateOrder', 'UpdateCustomer' // Good: Events 'OrderCreated', 'CustomerUpdated'
- Protect Invariants in Aggregates
class Order extends Aggregate { ship() { // Enforce business rules this.enforceInvariant( this.status === 'paid', 'Can only ship paid orders' ); this.enforceInvariant( this.shippingAddress !== null, 'Shipping address required' ); this.status = 'shipped'; this.recordEvent('OrderShipped', { orderId: this.id }); } }
Plugin Compatibility Matrix
Storage Plugin Compatibility
Plugin | MemoryPlugin | MySQLPlugin | Notes |
---|---|---|---|
ValidationPlugin | ✅ | ✅ | Always included automatically |
TimestampsPlugin | ✅ | ✅ | Works with all storage backends |
HTTPPlugin | ✅ | ✅ | Must be added last |
PositioningPlugin | ✅ | ✅ | MySQL supports transactions for atomic operations |
CorsPlugin | ✅ | ✅ | Works independently of storage |
JwtPlugin | ✅ | ✅ | Works independently of storage |
AuthorizationPlugin | ✅ | ✅ | Works with any storage backend |
ViewsPlugin | ✅ | ✅ | Works with any storage backend |
QueryLimitsPlugin | ✅ | ✅ | Works with any storage backend |
LoggingPlugin | ✅ | ✅ | Logs SQL queries for both backends |
OpenAPIPlugin | ✅ | ✅ | Generates docs for any backend |
SecurityPlugin | ✅ | ✅ | Works independently of storage |
VersioningPlugin | ✅ | ✅ | History tables work with both |
SQLPlugin | ✅ | ✅ | Required for both SQL backends |
SimplifiedRecordsPlugin | ✅ | ✅ | Works with any storage backend |
MicroservicesPlugin | ✅ | ✅ | Works independently of storage |
CQRSPlugin | ✅ | ✅ | Can use separate databases for read/write |
ApiGatewayPlugin | ✅ | ✅ | Works independently of storage |
DDDPlugin | ✅ | ✅ | Works with any storage backend |
Plugin Order Dependencies
Plugin | Must Come After | Must Come Before | Notes |
---|---|---|---|
Storage Plugins | - | All others | Foundation for everything |
ValidationPlugin | Storage | - | Auto-included with storage |
TimestampsPlugin | Storage | HTTPPlugin | Modifies data before HTTP |
PositioningPlugin | Storage | HTTPPlugin | Modifies data before HTTP |
VersioningPlugin | Storage, Timestamps | HTTPPlugin | Tracks after timestamps |
AuthorizationPlugin | Storage, JWT | HTTPPlugin | Needs auth before HTTP |
ViewsPlugin | Storage | HTTPPlugin | Filters data before response |
QueryLimitsPlugin | Storage | HTTPPlugin | Validates before execution |
LoggingPlugin | All data plugins | HTTPPlugin | Logs all operations |
HTTPPlugin | All others | - | Must be last |
CorsPlugin | - | HTTPPlugin | Can be anywhere before HTTP |
JwtPlugin | - | Authorization, HTTP | Provides auth for other plugins |
SecurityPlugin | - | HTTPPlugin | Can be anywhere before HTTP |
SimplifiedRecordsPlugin | Storage | - | Transforms responses |
OpenAPIPlugin | All others | - | Documents final API |
MicroservicesPlugin | Storage | HTTPPlugin | Can be used anywhere |
CQRSPlugin | Storage | HTTPPlugin | Should be early to intercept operations |
ApiGatewayPlugin | - | HTTPPlugin | Independent of storage, before HTTP |
DDDPlugin | Storage | HTTPPlugin | Should be early to set up domain model |
Feature Compatibility
Feature | Memory | MySQL | Notes |
---|---|---|---|
Transactions | ❌ | ✅ | MySQL supports ACID transactions |
Concurrent Writes | ⚠️ | ✅ | Memory may have race conditions |
Large Datasets | ❌ | ✅ | Memory limited by RAM |
Persistence | ❌ | ✅ | Memory data lost on restart |
Complex Joins | ✅ | ✅ | Both support SQL joins |
JSON Fields | ✅ | ✅ | Both support JSON data types |
Full-text Search | ❌ | ✅ | MySQL has FULLTEXT indexes |
Atomic Positioning | ❌ | ✅ | MySQL uses transactions |
Prepared Statements | ⚠️ | ✅ | Memory has basic support |
Connection Pooling | N/A | ✅ | MySQL supports multiple connections |
Recommended Plugin Combinations
Development/Testing:
api
.use(MemoryPlugin) // Fast in-memory storage
.use(TimestampsPlugin) // Track creation/updates
.use(LoggingPlugin) // Debug queries
.use(HTTPPlugin); // REST endpoints
Production API:
api
.use(MySQLPlugin, { connection }) // Production database
.use(TimestampsPlugin) // Track changes
.use(VersioningPlugin) // Version control
.use(JwtPlugin, { secret }) // Authentication
.use(AuthorizationPlugin) // Access control
.use(QueryLimitsPlugin) // Prevent abuse
.use(SecurityPlugin) // Security headers
.use(LoggingPlugin) // Audit trail
.use(HTTPPlugin); // REST endpoints
Public API:
api
.use(MySQLPlugin, { connection })
.use(CorsPlugin) // Cross-origin access
.use(QueryLimitsPlugin) // Rate limiting
.use(ViewsPlugin) // Field filtering
.use(OpenAPIPlugin) // API documentation
.use(HTTPPlugin); // REST endpoints
Security Features
Input Validation and Sanitization
The API includes comprehensive security measures to prevent common attacks:
Prototype Pollution Protection
All input data is sanitized to prevent prototype pollution attacks:
// These attacks are automatically blocked:
// POST /api/users
{
"__proto__": { "isAdmin": true },
"constructor": { "prototype": { "isAdmin": true } },
"name": "Attacker"
}
// Results in: BadRequestError - Potential prototype pollution detected
Circular Reference Protection
// Circular references are detected and rejected:
const data = { name: "Test" };
data.self = data; // Creates circular reference
// POST with circular data results in:
// BadRequestError - Circular reference detected in request data
Size Limits
Prevent DoS attacks with configurable limits:
const schema = new Schema({
// Array size limits
tags: {
type: 'array',
maxItems: 100 // Max 100 tags
},
// Object complexity limits
metadata: {
type: 'object',
maxKeys: 50, // Max 50 properties
maxDepth: 5 // Max 5 levels of nesting
}
});
SQL Injection Protection
- All queries use parameterized statements
- Filter values are sanitized and validated
- Field names are checked against schema
// These are safe:
?filter[name]=Robert'); DROP TABLE users;--
?filter[age][gt]=18 OR 1=1
// Properly escaped and parameterized
Field Access Validation
// Attempting to access system fields is blocked:
?filter[__proto__]=malicious
?filter[constructor.prototype]=evil
// Results in: BadRequestError - Invalid filter field
Format Validation with ReDoS Protection
All regex patterns are tested for ReDoS vulnerabilities:
const schema = new Schema({
email: {
type: 'string',
format: 'email' // Safe, ReDoS-protected pattern
},
custom: {
type: 'string',
validator: (value) => {
// Custom validation is time-limited
// Long-running validators are terminated
return complexValidation(value);
}
}
});
Error Context Sanitization
Sensitive data is automatically removed from error responses:
// Passwords, tokens, and secrets are sanitized:
try {
await api.resources.users.create({
email: 'test@example.com',
password: 'secret123',
apiToken: 'tok_secret'
});
} catch (error) {
// error.context will have password and apiToken removed
console.log(error.context);
// { email: 'test@example.com', password: '[REDACTED]', apiToken: '[REDACTED]' }
}
Type Definitions
Query Parameters
interface QueryParams {
filter?: Record<string, any>;
sort?: Array<{ field: string; direction: 'ASC' | 'DESC' }>;
page?: {
size?: number;
number?: number;
};
fields?: Record<string, string[]>;
include?: string;
joins?: boolean | string[];
excludeJoins?: string[];
}
Filter Operators
The filter
parameter supports advanced operators for complex queries:
// Basic equality
filter: { status: 'active' }
// Operator syntax
filter: {
field: {
operator: value
}
}
Available Operators
Operator | Description | Example | SQL Equivalent |
---|---|---|---|
(none) | Equals | { status: 'active' } |
WHERE status = 'active' |
eq |
Equals | { age: { eq: 25 } } |
WHERE age = 25 |
ne |
Not equals | { status: { ne: 'deleted' } } |
WHERE status != 'deleted' |
gt |
Greater than | { price: { gt: 100 } } |
WHERE price > 100 |
gte |
Greater than or equal | { age: { gte: 18 } } |
WHERE age >= 18 |
lt |
Less than | { stock: { lt: 10 } } |
WHERE stock < 10 |
lte |
Less than or equal | { price: { lte: 99.99 } } |
WHERE price <= 99.99 |
in |
In array | { status: { in: ['active', 'pending'] } } |
WHERE status IN ('active', 'pending') |
nin |
Not in array | { role: { nin: ['admin', 'root'] } } |
WHERE role NOT IN ('admin', 'root') |
like |
SQL LIKE | { name: { like: '%john%' } } |
WHERE name LIKE '%john%' |
ilike |
Case-insensitive LIKE | { email: { ilike: '%@GMAIL.COM' } } |
WHERE LOWER(email) LIKE LOWER('%@GMAIL.COM') |
contains |
Contains substring | { bio: { contains: 'developer' } } |
WHERE bio LIKE '%developer%' |
icontains |
Case-insensitive contains | { bio: { icontains: 'DEVELOPER' } } |
WHERE LOWER(bio) LIKE LOWER('%DEVELOPER%') |
startsWith |
Starts with | { name: { startsWith: 'Dr.' } } |
WHERE name LIKE 'Dr.%' |
endsWith |
Ends with | { email: { endsWith: '@company.com' } } |
WHERE email LIKE '%@company.com' |
null |
Is null | { deletedAt: { null: true } } |
WHERE deletedAt IS NULL |
notnull |
Is not null | { category: { notnull: true } } |
WHERE category IS NOT NULL |
between |
Between two values | { age: { between: [18, 65] } } |
WHERE age BETWEEN 18 AND 65 |
Notes:
- Multiple operators can be used on the same field:
{ age: { gte: 18, lt: 65 } }
- The
ilike
andicontains
operators useLOWER()
for databases without native case-insensitive support - For array fields,
in
andnin
check if the array contains any of the specified values - The
between
operator requires an array with exactly 2 values - In memory storage (AlaSQL), undefined fields also match
{ null: true }
queries
Schema Field Definition
interface FieldDefinition {
type: 'id' | 'string' | 'number' | 'boolean' | 'timestamp' | 'json' | 'array' | 'object';
required?: boolean;
default?: any;
min?: number;
max?: number;
unique?: boolean;
silent?: boolean;
refs?: {
resource: string;
join?: {
eager?: boolean;
type?: 'left' | 'inner';
fields?: string[];
excludeFields?: string[];
includeSilent?: boolean;
resourceField?: string;
preserveId?: boolean;
runHooks?: boolean;
hookContext?: string;
};
};
}
API Response Format
// Single resource
interface ResourceResponse {
data: {
type: string;
id: string;
attributes: Record<string, any>;
relationships?: Record<string, {
data: { type: string; id: string } | Array<{ type: string; id: string }>;
}>;
};
included?: Array<{
type: string;
id: string;
attributes: Record<string, any>;
}>;
}
// Multiple resources
interface CollectionResponse {
data: Array<ResourceResponse['data']>;
included?: ResourceResponse['included'];
meta?: {
total: number;
pageSize: number;
pageNumber: number;
totalPages: number;
};
links?: {
self: string;
first?: string;
last?: string;
prev?: string;
next?: string;
};
}
Hook Priority
Priority | Usage |
---|---|
0-20 | Early hooks (setup, initialization) |
30-40 | Validation, permission checks |
50 | Default priority |
60-70 | Business logic |
80-90 | Late hooks (cleanup, logging) |
95-100 | Final processing |
Lower numbers execute first.
Advanced Plugins
The plugins/advanced/
directory contains enterprise-grade plugins that extend JSON REST API with sophisticated features:
CachePlugin
Permission-aware caching system with multi-tier support and automatic invalidation.
import { CachePlugin } from 'json-rest-api/plugins/advanced';
api.use(CachePlugin, {
store: 'memory', // 'memory' or 'redis'
ttl: 300, // Time-to-live in seconds
maxItems: 1000, // Max items in memory cache
maxMemory: 100 * 1024 * 1024, // Max memory usage (100MB)
redis: redisClient, // Redis client if using Redis
permissionAware: true, // Cache based on user permissions
enableQueryCache: true, // Cache query results
enableGetCache: true, // Cache GET requests
warmupQueries: [] // Queries to warm cache on startup
});
Key Features:
- Permission-aware cache keys
- Automatic invalidation on mutations
- Multi-tier caching (L1: memory, L2: Redis)
- Query result signatures for validation
- Cache warming capabilities
ConfigPlugin
Configuration management with validation, hot-reload, and multi-source support.
import { ConfigPlugin } from 'json-rest-api/plugins/advanced';
api.use(ConfigPlugin, {
sources: ['env', 'file', 'args'],
envPrefix: 'API_',
configFile: 'config.json',
watch: true, // Enable hot-reload
validateOnChange: true,
schemas: {
port: {
type: 'number',
min: 1,
max: 65535,
required: true
},
host: {
type: 'string',
pattern: '^[a-zA-Z0-9.-]+$'
}
},
transformers: {
connectionString: (v, config) =>
`${config.protocol}://${config.host}:${config.port}`
}
});
// Usage
const port = api.config.get('port');
api.config.watch('debug', (newVal, oldVal) => {
console.log('Debug mode changed');
});
VersioningPlugin
Enhanced API versioning with multiple strategies and migration support.
import { VersioningPlugin } from 'json-rest-api/plugins/advanced';
api.use(VersioningPlugin, {
type: 'header', // 'header', 'path', 'query', 'accept'
header: 'x-api-version',
defaultVersion: '1',
versions: {
'1': { stable: true },
'2': { stable: true },
'3': { experimental: true }
}
});
// Add versioned resources
api.addVersionedResource('users', {
'1': { schema: userSchemaV1 },
'2': {
schema: userSchemaV2,
migrateFrom: '1',
migration: (data) => ({ ...data, newField: 'default' })
}
});
// Deprecate versions
api.deprecateVersion('1', {
sunset: '2024-12-31',
successor: '2'
});
ContextPlugin
AsyncLocalStorage-based context propagation for request tracking and debugging.
import { ContextPlugin } from 'json-rest-api/plugins/advanced';
api.use(ContextPlugin, {
enableRequestId: true,
enableTracing: true,
enableUserContext: true,
requestIdHeader: 'x-request-id',
correlationIdHeader: 'x-correlation-id'
});
// Access context anywhere
const requestId = api.context.get('requestId');
api.context.set('customValue', 'data');
// Context-aware logging
api.log.info('Operation completed'); // Auto-includes requestId, userId
// Run background tasks with context
api.runBackgroundTask('email-notification', async () => {
// Has access to parent context
});
InterceptorsPlugin
Request/response transformation pipeline with middleware-like capabilities.
import { InterceptorsPlugin } from 'json-rest-api/plugins/advanced';
api.use(InterceptorsPlugin);
// Add custom interceptor
api.interceptors.request.use({
name: 'auth-check',
priority: 10,
async process(context) {
if (!context.headers?.authorization) {
throw new Error('Unauthorized');
}
return context;
}
});
// Use common patterns
api.interceptors.request.use(
api.interceptors.common.rateLimit({
max: 100,
window: 60000
})
);
api.interceptors.response.use(
api.interceptors.common.transform({
response: (data) => ({
...data,
timestamp: Date.now()
})
})
);
TracingPlugin
Distributed tracing with OpenTelemetry compatibility.
import { TracingPlugin } from 'json-rest-api/plugins/advanced';
api.use(TracingPlugin, {
serviceName: 'my-api',
samplingRate: 0.1, // Sample 10% of requests
enableAutoInstrumentation: true,
enableHttpTracing: true,
enableDatabaseTracing: true
});
// Custom spans
await api.span('custom.operation', async (span) => {
span.setAttribute('user.id', userId);
span.addEvent('processing-started');
const result = await doWork();
span.addEvent('processing-completed', {
items: result.length
});
return result;
});
// Access trace data
// GET /api/tracing/export
// GET /api/tracing/stats
Using Advanced Plugins
All advanced plugins should be loaded after core plugins but before starting the API:
import { Api } from 'json-rest-api';
import {
CachePlugin,
ConfigPlugin,
ContextPlugin,
InterceptorsPlugin,
VersioningPlugin,
TracingPlugin
} from 'json-rest-api/plugins/advanced';
const api = new Api();
// Core plugins first
api.use(MemoryPlugin);
api.use(HTTPPlugin, { app });
// Advanced plugins (recommended order)
api.use(ConfigPlugin, configOptions); // Load config first
api.use(ContextPlugin, contextOptions); // Context propagation
api.use(InterceptorsPlugin); // Request pipeline
api.use(CachePlugin, cacheOptions); // Caching
api.use(VersioningPlugin, versionOptions); // API versioning
api.use(TracingPlugin, tracingOptions); // Distributed tracing
await api.start();
For detailed documentation and examples, see Advanced Plugins Guide.