Core Library and Plugins
This guide covers the fundamentals of json-rest-api, starting with basic usage and progressively covering more advanced features.
1. Basics
How to create an API with createApi
The simplest way to get started is using the createApi
helper function:
import { createApi, Schema } from 'json-rest-api';
import express from 'express';
const app = express();
const api = createApi({
storage: 'memory', // Use in-memory database
http: { app } // Attach to Express app
});
// Define a resource
api.addResource('books', new Schema({
title: { type: 'string', required: true },
author: { type: 'string', required: true },
isbn: { type: 'string' },
year: { type: 'number' }
}));
app.listen(3000);
This creates a fully functional REST API with:
GET /books
- List all booksGET /books/:id
- Get a specific bookPOST /books
- Create a new bookPUT /books/:id
- Update a bookDELETE /books/:id
- Delete a book
How to do the same thing by hand
For more control, you can manually configure the API:
import { Api, Schema, MemoryPlugin, HTTPPlugin, ValidationPlugin } from 'json-rest-api';
import express from 'express';
const app = express();
const api = new Api();
// Add plugins manually
api.use(ValidationPlugin); // Enable validation
api.use(MemoryPlugin); // Use in-memory storage
api.use(HTTPPlugin, { app }); // Enable HTTP endpoints
// Define resource
api.addResource('books', new Schema({
title: { type: 'string', required: true },
author: { type: 'string', required: true },
isbn: { type: 'string' },
year: { type: 'number' }
}));
app.listen(3000);
Core Plugins
The following plugins provide basic functionality:
- MemoryPlugin (
./plugins/core/memory.js
) - In-memory SQL database using AlaSQL - MySQLPlugin (
./plugins/core/mysql.js
) - MySQL/MariaDB storage with connection pooling - HTTPPlugin (
./plugins/core/http.js
) - REST endpoints with Express integration - ValidationPlugin (
./plugins/core/validation.js
) - Schema-based validation - PositioningPlugin (
./plugins/core/positioning.js
) - Ordered records - TimestampsPlugin (
./plugins/core/timestamps.js
) - Automatic created/updated timestamps
Note: VersioningPlugin is in core-extra, not core plugins.
What a resource file normally looks like
In a real project, you’d typically organize resources in separate files:
// resources/users.js
import { Schema } from 'json-rest-api';
export const userSchema = new Schema({
name: { type: 'string', required: true },
email: { type: 'string', required: true },
password: { type: 'string', silent: true }, // Never returned in responses
role: { type: 'string', default: 'user' },
active: { type: 'boolean', default: true }
});
export const userHooks = {
beforeInsert: async (context) => {
// Hash password before storing
context.data.password = await hashPassword(context.data.password);
},
afterGet: async (context) => {
// Add computed fields
context.result.displayName = `${context.result.name} (${context.result.role})`;
}
};
// resources/index.js
import { userSchema, userHooks } from './users.js';
import { postSchema, postHooks } from './posts.js';
export function registerResources(api) {
api.addResource('users', userSchema, { hooks: userHooks });
api.addResource('posts', postSchema, { hooks: postHooks });
}
How to integrate with Express
Starting from a fresh Express application:
// server.js
import express from 'express';
import { createApi } from 'json-rest-api';
import { registerResources } from './resources/index.js';
// Create Express app with required middleware
const app = express();
// IMPORTANT: These middleware are required
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies
// Optional but recommended middleware
app.use(helmet()); // Security headers
app.use(compression()); // Gzip compression
app.use(cors()); // CORS support
// Create API
const api = createApi({
storage: 'memory',
http: {
app,
prefix: '/api/v1' // Optional: prefix all routes
}
});
// Register your resources
registerResources(api);
// Your own routes can coexist
app.get('/', (req, res) => {
res.send('Welcome to my API');
});
// Error handling
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({
error: err.message || 'Internal server error'
});
});
app.listen(3000);
Structuring your API
Recommended directory structure:
project/
├── server.js # Main entry point
├── config/
│ └── database.js # Database configuration
├── resources/
│ ├── index.js # Resource registration
│ ├── users.js # User resource
│ ├── posts.js # Post resource
│ └── comments.js # Comment resource
├── hooks/
│ └── auth.js # Shared authentication hooks
└── plugins/
└── custom.js # Custom plugins
Memory vs MySQL - Totally Interchangeable
One of the key features is that storage plugins are completely interchangeable:
// Development - use memory
const api = createApi({
storage: 'memory',
http: { app }
});
// Production - use MySQL
const api = createApi({
storage: 'mysql',
mysql: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME
},
http: { app }
});
Your resource definitions, hooks, and business logic remain exactly the same!
2. Relations
Basic One-to-Many Example
Let’s create a blog with authors and posts:
// Define authors
api.addResource('authors', new Schema({
name: { type: 'string', required: true },
email: { type: 'string', required: true },
bio: { type: 'string' }
}));
// Define posts with author relationship
api.addResource('posts', new Schema({
title: { type: 'string', required: true },
content: { type: 'string', required: true },
authorId: {
type: 'id',
required: true,
refs: {
resource: 'authors', // Links to authors resource
join: {
eager: true, // Auto-include author data
fields: ['id', 'name'] // Only include these fields
}
}
},
published: { type: 'boolean', default: false }
}));
What the returned data looks like (JSON:API)
When you fetch a post, the response follows JSON:API format:
{
"data": {
"id": "1",
"type": "posts",
"attributes": {
"title": "My First Post",
"content": "This is the content...",
"authorId": "42",
"published": true
},
"relationships": {
"author": {
"data": { "type": "authors", "id": "42" }
}
}
},
"included": [
{
"id": "42",
"type": "authors",
"attributes": {
"name": "John Doe"
}
}
]
}
Using api.resources
The library provides an intuitive way to work with resources:
// Create an author
const author = await api.resources.authors.create({
name: 'Jane Smith',
email: 'jane@example.com',
bio: 'Tech writer and developer'
});
// Create a post for that author
const post = await api.resources.posts.create({
title: 'Understanding REST APIs',
content: 'REST APIs are...',
authorId: author.data.id
});
// Query posts by author
const authorPosts = await api.resources.posts.query({
filter: { authorId: author.data.id }
});
// Get a specific post (will include author data)
const fullPost = await api.resources.posts.get(post.data.id);
One-to-Many Relationships (Reverse)
Authors can have many posts. Define this as a virtual field:
api.addResource('authors', new Schema({
name: { type: 'string', required: true },
email: { type: 'string', required: true },
bio: { type: 'string' },
// Virtual field - not stored in database
posts: {
type: 'list',
virtual: true,
foreignResource: 'posts',
foreignKey: 'authorId', // Field in posts that references this author
join: {
eager: true, // Auto-load posts
include: ['categoryId'] // Include category for each post
},
defaultFilter: { published: true }, // Only show published posts
defaultSort: '-createdAt' // Newest first
}
}));
IMPORTANT: The foreign key field (authorId
in posts) must be marked as searchable: true
:
api.addResource('posts', new Schema({
// ... other fields ...
authorId: {
type: 'id',
required: true,
searchable: true, // REQUIRED for to-many relationships
refs: {
resource: 'authors'
}
}
}));
Complex Nested Relationships
Let’s add categories and tags:
// Categories
api.addResource('categories', new Schema({
name: { type: 'string', required: true },
slug: { type: 'string', required: true }
}));
// Tags
api.addResource('tags', new Schema({
name: { type: 'string', required: true },
color: { type: 'string' }
}));
// Posts with multiple relationships
api.addResource('posts', new Schema({
title: { type: 'string', required: true },
content: { type: 'string', required: true },
// Many-to-one: Post belongs to author
authorId: {
type: 'id',
required: true,
searchable: true,
refs: {
resource: 'authors',
join: {
eager: true,
fields: ['id', 'name', 'email'],
preserveId: true // Keep authorId field in response
}
}
},
// Many-to-one: Post belongs to category
categoryId: {
type: 'id',
refs: {
resource: 'categories',
join: {
eager: true,
fields: ['id', 'name', 'slug']
}
}
},
// Many-to-many: Posts have many tags (via junction table)
tags: {
type: 'list',
virtual: true,
foreignResource: 'post_tags',
foreignKey: 'postId',
join: {
eager: true,
include: ['tagId'] // Include tag details from junction
}
}
}));
// Junction table for many-to-many
api.addResource('post_tags', new Schema({
postId: {
type: 'id',
required: true,
searchable: true,
refs: { resource: 'posts' }
},
tagId: {
type: 'id',
required: true,
searchable: true,
refs: {
resource: 'tags',
join: {
eager: true,
fields: ['id', 'name', 'color']
}
}
}
}));
All Relationship Options
refs options:
resource
(string, required): Target resource namejoin
(object): Join configurationeager
(boolean): Auto-include related datafields
(array): Fields to include from related resourcepreserveId
(boolean): Keep the ID field when including datainclude
(array): Nested includes for the related resource
Virtual list field options:
type: 'list'
(required): Marks this as a to-many relationshipvirtual: true
(required): Not stored in databaseforeignResource
(string, required): Resource that references this oneforeignKey
(string, required): Field name in foreign resourcejoin
(object): Same as refs join optionsdefaultFilter
(object): Filter to apply to related recordsdefaultSort
(string): Sort order for related recordslimit
(number): Maximum number of related records
Nested Include Example
Fetch a post with author and the author’s country:
// Define countries
api.addResource('countries', new Schema({
name: { type: 'string', required: true },
code: { type: 'string', required: true }
}));
// Update authors to include country
api.addResource('authors', new Schema({
name: { type: 'string', required: true },
countryId: {
type: 'id',
refs: {
resource: 'countries',
join: { eager: true }
}
}
}));
// Fetch post with nested includes
const post = await api.resources.posts.get(1, {
include: 'authorId.countryId' // Include author's country
});
Response includes nested data:
{
"data": {
"id": "1",
"type": "posts",
"attributes": {
"title": "My Post",
"authorId": "10"
},
"relationships": {
"author": {
"data": { "type": "authors", "id": "10" }
}
}
},
"included": [
{
"id": "10",
"type": "authors",
"attributes": {
"name": "Jane Smith",
"countryId": "1"
},
"relationships": {
"country": {
"data": { "type": "countries", "id": "1" }
}
}
},
{
"id": "1",
"type": "countries",
"attributes": {
"name": "United States",
"code": "US"
}
}
]
}
3. Querying
Defining Searchable Fields
Fields must be marked as searchable
to be used in filters:
api.addResource('products', new Schema({
name: {
type: 'string',
required: true,
searchable: true // Can filter by name
},
description: { type: 'string' }, // NOT searchable
price: {
type: 'number',
searchable: true // Can filter by price
},
category: {
type: 'string',
searchable: true // Can filter by category
},
inStock: {
type: 'boolean',
searchable: true // Can filter by stock status
}
}));
Basic Filtering
Filter using query parameters:
GET /products?filter[category]=electronics
GET /products?filter[inStock]=true
GET /products?filter[name]=iPhone
In code:
// Find all electronics
const electronics = await api.resources.products.query({
filter: { category: 'electronics' }
});
// Find products in stock
const inStock = await api.resources.products.query({
filter: { inStock: true }
});
Advanced Filtering Operators
Use operators for complex queries:
// Price greater than 100
const expensive = await api.resources.products.query({
filter: { price: { gt: 100 } }
});
// Price between 50 and 200
const midRange = await api.resources.products.query({
filter: {
price: {
gte: 50,
lte: 200
}
}
});
// Name contains "phone"
const phones = await api.resources.products.query({
filter: { name: { like: '%phone%' } }
});
// Category in list
const techProducts = await api.resources.products.query({
filter: {
category: {
in: ['electronics', 'computers']
}
}
});
Available operators:
eq
: Equals (default)ne
: Not equalsgt
: Greater thangte
: Greater than or equallt
: Less thanlte
: Less than or equallike
: SQL LIKE patternin
: In arraynin
: Not in array
Sorting
Sort results using the sort
parameter:
// Sort by price ascending
const cheapFirst = await api.resources.products.query({
sort: 'price'
});
// Sort by price descending (prefix with -)
const expensiveFirst = await api.resources.products.query({
sort: '-price'
});
// Multiple sort fields
const sorted = await api.resources.products.query({
sort: ['-inStock', 'price'] // In stock first, then by price
});
Pagination
Control result size and pagination:
// Get first 10 products
const firstPage = await api.resources.products.query({
page: { size: 10, number: 1 }
});
// Get next 10 products
const secondPage = await api.resources.products.query({
page: { size: 10, number: 2 }
});
// Response includes pagination metadata
console.log(firstPage.meta);
// {
// page: { size: 10, number: 1, total: 4 },
// total: 37
// }
Field Selection
Optimize responses by selecting specific fields:
// Only get name and price
const summary = await api.resources.products.query({
fields: { products: ['name', 'price'] }
});
Combining Query Features
// Complex query example
const results = await api.resources.products.query({
filter: {
category: 'electronics',
price: { lte: 500 },
inStock: true
},
sort: '-price',
page: { size: 20, number: 1 },
fields: { products: ['name', 'price', 'category'] },
include: 'manufacturerId' // Include related manufacturer
});
Virtual Searchable Fields
You can define computed searchable fields:
api.addResource('users', new Schema({
firstName: { type: 'string', required: true },
lastName: { type: 'string', required: true }
}), {
searchableFields: {
// Virtual field that searches across multiple real fields
name: {
type: 'string',
resolve: (value) => ({
OR: [
{ firstName: { like: `%${value}%` } },
{ lastName: { like: `%${value}%` } }
]
})
},
// Map frontend field to database field
fullName: 'CONCAT(firstName, " ", lastName)'
}
});
// Now you can search by name
const johns = await api.resources.users.query({
filter: { name: 'john' } // Searches firstName OR lastName
});
4. Validation
How Validation Works with Schema
Every field in a schema can have validation rules:
const userSchema = new Schema({
username: {
type: 'string',
required: true,
min: 3, // Minimum length
max: 20, // Maximum length
pattern: /^[a-zA-Z0-9_]+$/, // Regex pattern
lowercase: true, // Convert to lowercase
trim: true // Remove whitespace
},
email: {
type: 'string',
required: true,
format: 'email', // Built-in email validation
validator: async (value) => {
// Custom async validation
const exists = await checkEmailExists(value);
if (exists) {
throw new Error('Email already registered');
}
}
},
age: {
type: 'number',
min: 0,
max: 150,
required: function(data) {
// Conditional requirement
return data.role === 'student';
}
},
role: {
type: 'string',
enum: ['admin', 'user', 'guest'], // Must be one of these
default: 'user'
},
tags: {
type: 'array',
maxItems: 10, // Maximum array length
items: { // Validate each item
type: 'string',
min: 2
}
},
metadata: {
type: 'object',
maxKeys: 20, // Maximum object properties
required: false,
default: {}
}
});
Understanding the Schema Object
The Schema class provides:
const schema = new Schema(structure, options);
// Options:
{
strictMode: true, // Reject unknown fields (default: true)
maxItems: 1000, // Max array size (default: 1000)
maxKeys: 100, // Max object keys (default: 100)
maxDepth: 10, // Max nesting depth (default: 10)
emptyAsNull: false, // Treat empty strings as null
canBeNull: false // Allow null values
}
// Methods:
await schema.validate(data, options); // Validate data
schema.use(plugin); // Add schema plugin
schema.registerType(name, handler); // Custom types
schema.registerParam(name, handler); // Custom validators
Built-in Types
string
: Text valuesnumber
: Numeric values (integer or float)boolean
: true/falseid
: String or number ID (converted to string in JSON:API)date
: Date only (YYYY-MM-DD)datetime
/dateTime
: Date and timetimestamp
: Unix timestamparray
: Array of valuesobject
: Nested objectserialize
: Any value (JSON serialized for storage)blob
: Binary data
Built-in Validators
General:
required
: Field must be presentdefault
: Default value if not providedenum
: Must be one of listed valuesvalidator
: Custom validation function
Strings:
min
/max
: Length constraintslength
: Exact lengthpattern
: Regex patternformat
: Built-in formats (email, url, phone, uuid)trim
: Remove whitespaceuppercase
/lowercase
: Case conversionnotEmpty
: Not empty string
Numbers:
min
/max
: Value constraintsinteger
: Must be integerpositive
: Must be positivenegative
: Must be negative
Arrays:
maxItems
: Maximum lengthminItems
: Minimum lengthunique
: No duplicatesitems
: Schema for each item
Objects:
maxKeys
: Maximum propertiesstructure
: Nested schema
Validation Process
// Validation happens automatically before insert/update
try {
await api.resources.users.create({
username: 'jd', // Too short!
email: 'invalid-email',
age: -5
});
} catch (error) {
console.log(error.errors);
// [
// { field: 'username', message: 'Minimum length is 3' },
// { field: 'email', message: 'Invalid email format' },
// { field: 'age', message: 'Minimum value is 0' }
// ]
}
Custom Validators
// Synchronous validator
const isValidUsername = {
validator: (value) => {
if (reservedUsernames.includes(value)) {
return 'Username is reserved';
}
return true; // Valid
}
};
// Asynchronous validator
const uniqueEmail = {
validator: async (value, { context }) => {
const existing = await context.api.resources.users.query({
filter: { email: value }
});
if (existing.data.length > 0) {
// Check if it's an update to the same record
if (context.method === 'update' &&
existing.data[0].id === context.id) {
return true; // Same record, allow
}
return 'Email already exists';
}
return true;
}
};
// Use in schema
new Schema({
username: { type: 'string', ...isValidUsername },
email: { type: 'string', ...uniqueEmail }
});
Conditional Validation
const orderSchema = new Schema({
status: {
type: 'string',
enum: ['pending', 'processing', 'shipped', 'delivered']
},
shippingAddress: {
type: 'string',
required: function(data) {
// Required only when status is shipped/delivered
return ['shipped', 'delivered'].includes(data.status);
}
},
trackingNumber: {
type: 'string',
validator: function(value, { data }) {
if (data.status === 'shipped' && !value) {
return 'Tracking number required for shipped orders';
}
return true;
}
}
});
Validation Modes
// Strict validation for insert (all required fields must be present)
await api.resources.users.create({
username: 'john',
email: 'john@example.com'
// Missing required fields will cause error
});
// Partial validation for update (only validate provided fields)
await api.resources.users.update(userId, {
email: 'newemail@example.com'
// Only email is validated, other fields unchanged
});
// Full record validation for update
await api.resources.users.update(userId, {
email: 'newemail@example.com'
}, {
validateFullRecord: true // Fetch and validate entire record
});
5. Positioning
How Positioning Works
The positioning plugin maintains ordered records using a position field:
api.addResource('tasks', new Schema({
title: { type: 'string', required: true },
position: { type: 'number' } // Managed by positioning plugin
}), {
positioning: {
field: 'position', // Field name (default: 'position')
startAt: 1000, // Starting position (default: 1000)
increment: 1000 // Gap between positions (default: 1000)
}
});
Why It’s Invaluable
- User-defined ordering: Let users arrange items (drag-and-drop)
- Stable sorting: Maintains order even with same timestamps
- Efficient reordering: Move items without updating everything
- Gap-based positioning: Minimizes position conflicts
Full Tutorial
Basic Usage
// Create tasks - positions are auto-assigned
const task1 = await api.resources.tasks.create({
title: 'First task'
});
console.log(task1.data.attributes.position); // 1000
const task2 = await api.resources.tasks.create({
title: 'Second task'
});
console.log(task2.data.attributes.position); // 2000
const task3 = await api.resources.tasks.create({
title: 'Third task'
});
console.log(task3.data.attributes.position); // 3000
Inserting Between Items
// Insert before task2 using beforeId
const task1_5 = await api.resources.tasks.create({
title: 'Task 1.5',
beforeId: task2.data.id // Virtual field
});
console.log(task1_5.data.attributes.position); // 1500
// Tasks are now ordered: task1 (1000), task1_5 (1500), task2 (2000), task3 (3000)
Moving Items
// Move task3 to the beginning
await api.resources.tasks.update(task3.data.id, {
beforeId: task1.data.id
});
// Move task1 to the end
await api.resources.tasks.update(task1.data.id, {
beforeId: null // null means end of list
});
Querying Ordered Items
// Get tasks in position order
const orderedTasks = await api.resources.tasks.query({
sort: 'position' // Sort by position field
});
Scoped Positioning
Position items within groups:
api.addResource('board_cards', new Schema({
title: { type: 'string', required: true },
boardId: { type: 'id', required: true, searchable: true },
columnId: { type: 'id', required: true, searchable: true },
position: { type: 'number' }
}), {
positioning: {
field: 'position',
scope: ['boardId', 'columnId'] // Separate positions per board/column
}
});
// Cards in different columns have independent positions
const card1 = await api.resources.board_cards.create({
title: 'Card 1',
boardId: 1,
columnId: 1
}); // position: 1000 in column 1
const card2 = await api.resources.board_cards.create({
title: 'Card 2',
boardId: 1,
columnId: 2
}); // position: 1000 in column 2 (different scope)
Handling Position Conflicts
The plugin automatically handles conflicts:
// If positions get too close, the plugin rebases them
// This happens automatically when gaps get too small
// Manual rebase if needed
await api.rebasePositions('tasks', {
scope: { projectId: 5 }, // Optional scope
startAt: 1000,
increment: 1000
});
Advanced Features
// Custom position calculation
api.addResource('priorities', new Schema({
name: { type: 'string', required: true },
priority: { type: 'number' }
}), {
positioning: {
field: 'priority',
startAt: 100,
increment: 10,
// Custom position calculator
calculatePosition: async (context) => {
const { beforeId, data } = context;
// Custom logic for critical items
if (data.critical) {
return 1; // Always first
}
// Default behavior for others
return null; // Let plugin calculate
}
}
});
// Disable positioning for specific operations
await api.resources.tasks.update(taskId, data, {
positioning: { enabled: false } // Skip position calculation
});
Best Practices
- Always use the position field for ordering: Don’t rely on creation order
- Use beforeId for user-driven reordering: It’s more intuitive than setting positions
- Scope positions when needed: Keep independent orderings for different contexts
- Let the plugin manage positions: Don’t set position values manually
- Use appropriate increments: Larger increments (1000) handle more reorderings
6. Hooks
Resource-Wide Hooks
Define hooks that run for specific resources:
api.addResource('posts', postSchema, {
hooks: {
// Before hooks - modify data before operations
beforeInsert: async (context) => {
// Auto-generate slug from title
context.data.slug = context.data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-');
// Set author from authenticated user
context.data.authorId = context.options.user?.id;
},
beforeUpdate: async (context) => {
// Track who modified the post
context.data.lastModifiedBy = context.options.user?.id;
// Prevent changing author
if (context.data.authorId) {
delete context.data.authorId;
}
},
beforeDelete: async (context) => {
// Check permissions
const post = await context.api.resources.posts.get(context.id);
if (post.data.attributes.authorId !== context.options.user?.id) {
throw new ForbiddenError('Can only delete your own posts');
}
},
// After hooks - process results
afterGet: async (context) => {
// Add computed fields
const post = context.result;
// Calculate reading time
const words = post.content.split(' ').length;
post.readingTime = Math.ceil(words / 200); // 200 words per minute
// Add author's full name if included
if (post.author) {
post.authorName = post.author.name;
}
},
afterQuery: async (context) => {
// Process all results
for (const post of context.results) {
// Add summary for list views
post.summary = post.content.substring(0, 200) + '...';
}
// Add metadata
context.meta.generatedAt = new Date().toISOString();
},
afterInsert: async (context) => {
// Send notification
await notifyFollowers(context.result.authorId, {
type: 'new_post',
postId: context.result.id,
title: context.result.title
});
// Update author's post count
await context.api.query(
`UPDATE authors SET postCount = postCount + 1 WHERE id = ?`,
[context.result.authorId]
);
},
afterUpdate: async (context) => {
// Clear cache
await cache.delete(`post:${context.id}`);
// Log activity
await context.api.resources.activity_logs.create({
resource: 'posts',
action: 'update',
resourceId: context.id,
userId: context.options.user?.id,
changes: context.data
});
},
afterDelete: async (context) => {
// Cascade delete comments
await context.api.query(
`DELETE FROM comments WHERE postId = ?`,
[context.id]
);
// Update author's post count
await context.api.query(
`UPDATE authors SET postCount = postCount - 1 WHERE id = ?`,
[context.originalRecord.authorId]
);
}
}
});
API-Wide Hooks
Define hooks that run for all resources:
// Authentication hook - runs for all resources
api.hook('beforeGet', async (context) => {
// Public resources don't need auth
const publicResources = ['categories', 'tags'];
if (publicResources.includes(context.options.type)) {
return;
}
// Require authentication for other resources
if (!context.options.user) {
throw new UnauthorizedError('Authentication required');
}
});
// Audit logging - runs for all modifications
api.hook('afterInsert', async (context) => {
await logActivity({
action: 'create',
resource: context.options.type,
resourceId: context.result.id,
userId: context.options.user?.id,
data: context.data
});
}, 100); // Priority 100 - runs after resource hooks
// Rate limiting
const requestCounts = new Map();
api.hook('beforeQuery', async (context) => {
const userId = context.options.user?.id || context.options.ip;
const key = `${userId}:${Date.now() / 60000 | 0}`; // Per minute
const count = (requestCounts.get(key) || 0) + 1;
requestCounts.set(key, count);
if (count > 100) { // 100 requests per minute
throw new RateLimitError('Too many requests');
}
});
// Transform all responses
api.hook('transformResult', async (context) => {
// Add metadata to all responses
if (context.result) {
context.meta = context.meta || {};
context.meta.version = '1.0';
context.meta.timestamp = new Date().toISOString();
}
});
Complete Hook Lifecycle
The complete order of hook execution:
For GET operations:
beforeGet
- Modify request, check permissions- Storage plugin fetches data
afterGet
- Process result, add computed fieldstransformResult
- Final response transformation
For QUERY operations:
beforeQuery
- Modify filters, check permissions- Storage plugin queries data
afterQuery
- Process results arrayafterGet
- Runs for each result (optional)transformResult
- Run for each result
For INSERT operations:
beforeValidate
- Pre-process data- Validation runs
afterValidate
- Post-validation processingbeforeInsert
- Final data modifications- Storage plugin inserts data
afterInsert
- Post-insert actionstransformResult
- Format response
For UPDATE operations:
beforeValidate
- Pre-process data- Validation runs
afterValidate
- Post-validation processingbeforeUpdate
- Final data modifications- Storage plugin updates data
afterUpdate
- Post-update actionstransformResult
- Format response
For DELETE operations:
beforeDelete
- Check permissions, prepare- Storage plugin deletes data
afterDelete
- Cleanup, cascade deletes
Hook Context Object
Every hook receives a context object:
{
api: Api, // API instance
method: string, // 'get', 'query', 'insert', 'update', 'delete'
options: {
type: string, // Resource type
user: object, // Authenticated user
artificialDelay: number,
// ... other options
},
// Method-specific properties:
id: any, // For get, update, delete
data: object, // For insert, update
params: object, // For query (filters, sort, etc.)
result: object, // For afterX hooks
results: array, // For afterQuery
meta: object, // Response metadata
errors: array, // Validation errors
// Hook modifications:
skipDefaults: boolean, // Skip default behaviors
// ... custom properties
}
Hook Priorities
Control hook execution order with priorities:
// Lower numbers run first (default is 50)
// Run authentication first
api.hook('beforeGet', authHook, 10);
// Then check permissions
api.hook('beforeGet', permissionHook, 20);
// Then apply business logic
api.hook('beforeGet', businessLogicHook, 30);
// Resource-specific hooks run at priority 50
// Logging runs last
api.hook('afterGet', loggingHook, 90);
Stopping Hook Execution
Return false
to stop the hook chain:
api.hook('beforeInsert', async (context) => {
if (context.options.type === 'posts') {
const dailyLimit = await checkDailyPostLimit(context.options.user);
if (dailyLimit.exceeded) {
context.errors.push({
field: 'general',
message: 'Daily post limit exceeded'
});
return false; // Stop execution
}
}
});
Modifying Hook Behavior
// Skip remaining hooks
api.hook('beforeUpdate', async (context) => {
if (context.data.skipValidation) {
context.skipDefaults = true; // Skip default behaviors
context.skipHooks = ['validate']; // Skip specific hooks
}
});
// Modify query in flight
api.hook('beforeQuery', async (context) => {
// Add default filter
context.params.filter = context.params.filter || {};
context.params.filter.deleted = false;
// Force sorting
if (!context.params.sort) {
context.params.sort = '-createdAt';
}
});
// Transform errors
api.hook('beforeSend', async (context) => {
if (context.errors && context.errors.length > 0) {
// Transform errors for frontend
context.errors = context.errors.map(err => ({
code: err.code || 'VALIDATION_ERROR',
title: 'Validation Failed',
detail: err.message,
source: { pointer: `/data/attributes/${err.field}` }
}));
}
});
7. API Usage
Using the Module Programmatically
The API can be used programmatically without HTTP:
import { createApi, Schema } from 'json-rest-api';
// Create API without HTTP
const api = createApi({
storage: 'memory' // No http option
});
// Define resources as usual
api.addResource('users', new Schema({
name: { type: 'string', required: true },
email: { type: 'string', required: true }
}));
// Use the API programmatically
async function main() {
// Create a user
const user = await api.resources.users.create({
name: 'Alice',
email: 'alice@example.com'
});
console.log('Created user:', user.data);
// Query users
const users = await api.resources.users.query({
filter: { name: { like: '%Ali%' } }
});
console.log('Found users:', users.data);
// Update user
const updated = await api.resources.users.update(user.data.id, {
name: 'Alice Smith'
});
console.log('Updated user:', updated.data);
// Get with relationships
const fullUser = await api.resources.users.get(user.data.id, {
include: 'posts,comments'
});
console.log('User with relations:', fullUser);
}
main().catch(console.error);
Direct API Methods
// All methods return JSON:API formatted responses
// CREATE
const result = await api.insert(data, options);
// or
const result = await api.resources[type].create(data, options);
// READ
const result = await api.get(id, options);
const results = await api.query(params, options);
// or
const result = await api.resources[type].get(id, options);
const results = await api.resources[type].query(params, options);
// UPDATE
const result = await api.update(id, data, options);
// or
const result = await api.resources[type].update(id, data, options);
// DELETE
await api.delete(id, options);
// or
await api.resources[type].delete(id, options);
Options Parameter
const options = {
type: 'users', // Resource type (required for direct API methods)
user: { // Authenticated user
id: 123,
roles: ['admin'],
permissions: ['read', 'write']
},
include: 'posts,comments', // Relationships to include
fields: { // Sparse fieldsets
users: ['name', 'email'],
posts: ['title', 'content']
},
artificialDelay: 100, // Testing: add delay
skipHooks: ['afterGet'], // Skip specific hooks
transaction: trx, // Database transaction
// ... custom options for plugins
};
Batch Operations
// Batch create
const users = await api.resources.users.batch.create([
{ name: 'User 1', email: 'user1@example.com' },
{ name: 'User 2', email: 'user2@example.com' },
{ name: 'User 3', email: 'user3@example.com' }
]);
// Batch update
const updates = await api.resources.users.batch.update([
{ id: 1, data: { name: 'Updated User 1' } },
{ id: 2, data: { name: 'Updated User 2' } }
]);
// Batch delete
await api.resources.users.batch.delete([1, 2, 3]);
Working with Transactions
// MySQL transactions
const connection = await api.getConnection();
const trx = await connection.beginTransaction();
try {
// All operations use the same transaction
const author = await api.resources.authors.create({
name: 'New Author'
}, { transaction: trx });
const post = await api.resources.posts.create({
title: 'New Post',
authorId: author.data.id
}, { transaction: trx });
await trx.commit();
} catch (error) {
await trx.rollback();
throw error;
} finally {
connection.release();
}
Custom Queries
// Direct SQL queries (when needed)
const results = await api.query(
'SELECT * FROM users WHERE created_at > ? ORDER BY name',
[new Date('2024-01-01')]
);
// Using QueryBuilder
import { QueryBuilder } from 'json-rest-api';
const query = new QueryBuilder('users', api)
.select('users.*', 'COUNT(posts.id) as post_count')
.leftJoin('posts', 'posts.authorId = users.id')
.where('users.active = ?', true)
.groupBy('users.id')
.having('COUNT(posts.id) > ?', 5)
.orderBy('post_count', 'DESC')
.limit(10);
const sql = query.build();
const results = await api.query(sql.sql, sql.args);
Error Handling
import {
ValidationError,
NotFoundError,
UnauthorizedError,
ForbiddenError,
ConflictError
} from 'json-rest-api';
try {
await api.resources.users.create({
name: 'a' // Too short!
});
} catch (error) {
if (error instanceof ValidationError) {
console.log('Validation errors:', error.errors);
// [{ field: 'name', message: 'Minimum length is 2' }]
} else if (error instanceof NotFoundError) {
console.log('Resource not found');
} else if (error instanceof UnauthorizedError) {
console.log('Authentication required');
} else {
// Handle other errors
console.error('Unexpected error:', error);
}
}
Events and Notifications
// Emit custom events from hooks
api.hook('afterInsert', async (context) => {
if (context.options.type === 'orders') {
api.emit('order:created', {
order: context.result,
user: context.options.user
});
}
});
// Listen for events
api.on('order:created', async ({ order, user }) => {
await sendOrderConfirmation(order, user);
await updateInventory(order);
await notifyWarehouse(order);
});
Testing
import { createApi } from 'json-rest-api';
import { test } from 'your-test-framework';
test('user creation', async () => {
// Create test API with memory storage
const api = createApi({ storage: 'memory' });
api.addResource('users', userSchema);
// Test user creation
const user = await api.resources.users.create({
name: 'Test User',
email: 'test@example.com'
});
expect(user.data.attributes.name).toBe('Test User');
expect(user.data.id).toBeDefined();
// Test query
const users = await api.resources.users.query({
filter: { email: 'test@example.com' }
});
expect(users.data).toHaveLength(1);
expect(users.data[0].id).toBe(user.data.id);
});
Performance Monitoring
// Add performance monitoring
api.hook('beforeQuery', async (context) => {
context.startTime = Date.now();
});
api.hook('afterQuery', async (context) => {
const duration = Date.now() - context.startTime;
if (duration > 1000) { // Log slow queries
console.warn('Slow query detected:', {
type: context.options.type,
duration: `${duration}ms`,
filters: context.params.filter,
resultCount: context.results.length
});
}
// Add to response metadata
context.meta.queryTime = `${duration}ms`;
});
Advanced Patterns
// 1. Multi-tenancy
api.hook('beforeQuery', async (context) => {
// Always filter by tenant
context.params.filter = context.params.filter || {};
context.params.filter.tenantId = context.options.user.tenantId;
});
// 2. Soft deletes
api.hook('beforeDelete', async (context) => {
// Convert delete to update
context.method = 'update';
context.data = {
deletedAt: new Date().toISOString(),
deletedBy: context.options.user.id
};
return false; // Don't actually delete
});
// 3. Caching
const cache = new Map();
api.hook('afterGet', async (context) => {
// Cache the result
const key = `${context.options.type}:${context.id}`;
cache.set(key, {
data: context.result,
timestamp: Date.now()
});
});
api.hook('beforeGet', async (context) => {
const key = `${context.options.type}:${context.id}`;
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < 60000) { // 1 minute
context.result = cached.data;
context.fromCache = true;
return false; // Skip database query
}
});
Summary
This guide covered the core functionality of json-rest-api:
- Basics: Creating APIs, using plugins, integrating with Express
- Relations: Defining relationships, working with related data
- Querying: Filtering, sorting, pagination, field selection
- Validation: Schema validation, custom validators
- Positioning: Maintaining ordered records
- Hooks: Lifecycle hooks for customization
- API Usage: Programmatic usage, error handling, advanced patterns
The plugin architecture allows you to start simple and add features as needed. Storage plugins are interchangeable, making it easy to develop with in-memory storage and deploy with MySQL.
For more advanced features, see the Core Extra Plugins documentation.