@rabstack/rab-api
v1.12.0
Published
A TypeScript REST API framework built on Express.js with decorator-based routing, dependency injection, and built-in validation
Downloads
288
Readme
rab-api
A TypeScript REST API framework built on Express.js with decorator-based routing, dependency injection, and built-in validation.
Features
- 🎯 Decorator-based routing with TypeScript
- 🔒 Built-in JWT authentication
- ✅ Request validation with Joi schemas
- 💉 Dependency injection (TypeDI)
- 🔐 Role-based access control
- ⚡ Response caching with purge support
- 📝 Full TypeScript type safety
- 🚀 Production-ready
Installation
npm install rab-apiPeer dependencies:
npm install express joi typedi reflect-metadata jsonwebtoken compose-middlewareQuick Start
1. Create a controller:
import { Get, RabApiGet, GetController } from 'rab-api';
type ControllerT = GetController<{ status: string }>;
@Get('/health')
export class HealthCheck implements RabApiGet<ControllerT> {
handler: ControllerT['request'] = async () => {
return { status: 'ok' };
};
}2. Bootstrap your app:
import { RabApi } from 'rab-api';
import express from 'express';
const app = RabApi.createApp({
auth: {
jwt: {
secret_key: process.env.JWT_SECRET!,
algorithms: ['HS256'],
},
},
});
app.use(express.json());
app.route({
basePath: '/api',
controllers: [HealthCheck],
});
app.listen(3000);Core Concepts
Controllers
Controllers handle HTTP requests using decorators:
import { Post, RabApiPost, PostController } from 'rab-api';
import * as Joi from 'joi';
type CreateUserBody = { name: string; email: string };
type UserResponse = { id: string; name: string; email: string };
type ControllerT = PostController<CreateUserBody, UserResponse>;
const schema = Joi.object({
name: Joi.string().required(),
email: Joi.string().email().required(),
});
@Post('/users', { bodySchema: schema })
export class CreateUser implements RabApiPost<ControllerT> {
handler: ControllerT['request'] = async (request) => {
return { id: '1', ...request.body };
};
}Available decorators:
@Get(path, options?)- GET requests@Post(path, options?)- POST requests@Put(path, options?)- PUT requests@Patch(path, options?)- PATCH requests@Delete(path, options?)- DELETE requests
Dependency Injection
Controllers support constructor injection via TypeDI:
import { Injectable } from 'rab-api';
@Injectable()
class UserService {
async findAll() {
return [];
}
}
@Get('/users')
export class ListUsers implements RabApiGet<ControllerT> {
constructor(private userService: UserService) {}
handler: ControllerT['request'] = async () => {
return await this.userService.findAll();
};
}Routing
Group related controllers with routers:
app.route({
basePath: '/users',
controllers: [ListUsers, CreateUser, UpdateUser, DeleteUser],
});
// Nested routes
app.route({
basePath: '/users',
controllers: [
ListUsers,
RabApi.createRouter({
basePath: '/:userId/posts',
controllers: [ListPosts, CreatePost],
}),
],
});Validation
Request Body
const createProductSchema = Joi.object({
name: Joi.string().min(3).required(),
price: Joi.number().positive().required(),
});
@Post('/products', { bodySchema: createProductSchema })
export class CreateProduct implements RabApiPost<ControllerT> {
handler: ControllerT['request'] = async (request) => {
const { name, price } = request.body; // validated
return { id: '1', name, price };
};
}Query Parameters
const listSchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(10),
});
@Get('/products', { querySchema: listSchema })
export class ListProducts implements RabApiGet<ControllerT> {
handler: ControllerT['request'] = async (request) => {
const { page, limit } = request.query; // validated
return { items: [], page, limit };
};
}Authentication
JWT Setup
const app = RabApi.createApp({
auth: {
jwt: {
secret_key: process.env.JWT_SECRET!,
algorithms: ['HS256'],
},
},
});Protected Routes
Routes are protected by default. Make a route public:
@Post('/auth/login', { isProtected: false })
export class Login implements RabApiPost<ControllerT> {
// Public endpoint
}Access authenticated user:
@Get('/profile')
export class GetProfile implements RabApiGet<ControllerT> {
handler: ControllerT['request'] = async (request) => {
const user = request.auth; // JWT payload
return { userId: user.userId };
};
}Authorization
Use permission-based access control:
@Post('/admin/users', { permission: 'canCreateUser' })
export class CreateUser implements RabApiPost<ControllerT> {
// Only users with 'canCreateUser' permission
}Integrate with @softin/rab-access:
import { Rab } from '@softin/rab-access';
const permissions = Rab.schema({
canCreateUser: [Rab.grant('admin'), Rab.grant('superAdmin')],
canDeleteUser: [Rab.grant('superAdmin')],
});Error Handling
Built-in exceptions:
import {
BadRequestException,
UnauthorizedException,
ForbiddenException,
NotFoundException,
ConflictException,
} from 'rab-api';
@Get('/users/:id')
export class GetUser implements RabApiGet<ControllerT> {
handler: ControllerT['request'] = async (request) => {
const user = await findUser(request.params.id);
if (!user) throw new NotFoundException('User not found');
return user;
};
}Custom error handler:
const app = RabApi.createApp({
errorHandler: (err, req, res, next) => {
if (err instanceof RabApiError) {
return res.status(err.statusCode).json({ error: err.message });
}
return res.status(500).json({ error: 'Internal error' });
},
});Middleware
Apply middleware at different levels:
// Route level
@Get('/users', { pipes: [loggerMiddleware] })
export class ListUsers {}
// Router level
app.route({
basePath: '/api',
pipes: [corsMiddleware, loggerMiddleware],
controllers: [/* ... */],
});
// Conditional
const conditionalAuth = (route) => {
return route.isProtected ? [authMiddleware] : [];
};
app.route({
pipes: [conditionalAuth],
controllers: [/* ... */],
});Caching
Built-in response caching with cache invalidation (purge) support.
Setup
const app = RabApi.createApp({
cache: {
adapter: myCacheAdapter, // Implement ICacheAdapter
defaultTtl: 900, // 15 minutes default
},
});Cache Adapter Interface
interface ICacheAdapter {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, data: T, ttlSeconds: number): Promise<void>;
del(key: string): Promise<void>;
}Enable Caching
// Simple - uses defaults (ttl: 900s, strategy: url-query)
@Get('/products', { cache: true })
export class ListProducts {}
// With options
@Get('/products', {
cache: {
ttl: 600, // 10 minutes
strategy: 'url-query', // Include query params in cache key
}
})
export class ListProducts {}Cache Strategies
url-query(default): Cache key includes path + sorted query params/products?page=1&limit=10→ key:/products?limit=10&page=1
url-params: Cache key is just the resolved path/products?page=1&limit=10→ key:/products
How Cache Keys Work
Cache keys are deterministically generated from the request URL to ensure consistent cache hits. Understanding how keys are built helps you design effective caching and purge strategies.
Key Generation Process
- Extract the path: The resolved path (with route params filled in) is extracted from the request
- Apply strategy: Based on the configured strategy, query parameters may be included
- Sort & encode: Query params are sorted alphabetically and URL-encoded to prevent collisions
Request: GET /stores/123/products?page=2&limit=10&sort=name
url-params strategy → /stores/123/products
url-query strategy → /stores/123/products?limit=10&page=2&sort=name
↑ params sorted alphabeticallyWhy Sorting Matters
Query parameters are sorted alphabetically to ensure the same cache key regardless of parameter order:
// These requests produce the SAME cache key:
GET /products?limit=10&page=1
GET /products?page=1&limit=10
// Both → /products?limit=10&page=1URL Encoding for Safety
Special characters in query values are URL-encoded to prevent cache key collisions:
// Different requests, different cache keys:
GET /search?q=a&b=2 → /search?b=2&q=a
GET /search?q=a%26b=2 → /search?q=a%26b%3D2Strategy Selection Guide
| Strategy | Use When | Cache Key Example |
|----------|----------|-------------------|
| url-query | Response varies by query params (pagination, filters) | /products?limit=10&page=1 |
| url-params | Response is the same regardless of query params | /products/123 |
Cache Purge
Purge (invalidate) cache keys after mutations to keep cached data fresh.
How Purge Works
- After successful response: Purge runs only after the handler returns successfully
- Pattern resolution:
:paramplaceholders are replaced with actual request param values - Background execution: Purge operations run asynchronously (non-blocking)
- Silent failures: Purge errors are caught and ignored to avoid breaking the main response
Purge Patterns
Static patterns - Exact cache keys to invalidate:
@Post('/products', {
cache: {
purge: ['/products'], // Purge the list endpoint
}
})
export class CreateProduct {}Dynamic patterns - Use :param placeholders resolved from request params:
@Put('/products/:id', {
cache: {
purge: [
'/products', // Purge the list
'/products/:id', // :id → resolved to actual value (e.g., /products/123)
]
}
})
export class UpdateProduct {}Function patterns - Full control with access to the request object:
@Delete('/products/:id', {
cache: {
purge: [
(req) => `/products/${req.params.id}`,
(req) => `/categories/${req.body.categoryId}/products`, // Access body
(req) => [ // Return multiple keys
`/products/${req.params.id}`,
`/products/${req.params.id}/reviews`,
],
]
}
})
export class DeleteProduct {}Purge Flow Diagram
Request: DELETE /stores/123/products/456
1. Handler executes successfully
2. Purge patterns resolved:
- '/stores/:storeId/products' → '/stores/123/products'
- '/stores/:storeId/products/:id' → '/stores/123/products/456'
3. cacheAdapter.del() called for each key (async, non-blocking)
4. Response returned to client immediatelyCommon Purge Patterns
// List + detail invalidation
@Put('/products/:id', {
cache: {
purge: ['/products', '/products/:id']
}
})
// Hierarchical invalidation
@Delete('/stores/:storeId/products/:id', {
cache: {
purge: [
'/stores/:storeId/products', // List
'/stores/:storeId/products/:id', // Detail
'/stores/:storeId', // Parent
]
}
})
// Cross-entity invalidation
@Post('/orders', {
cache: {
purge: [
'/orders',
(req) => `/users/${req.auth.userId}/orders`, // User's orders
(req) => req.body.items.map(i => `/products/${i.productId}/stock`),
]
}
})Example: Redis Adapter
import Redis from 'ioredis';
import { ICacheAdapter } from 'rab-api';
const redis = new Redis();
const redisCacheAdapter: ICacheAdapter = {
async get(key) {
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
},
async set(key, data, ttl) {
await redis.setex(key, ttl, JSON.stringify(data));
},
async del(key) {
await redis.del(key);
},
};
const app = RabApi.createApp({
cache: { adapter: redisCacheAdapter },
});Advanced Features
Controller Type Helpers
// Type helpers for controllers
PostController<TBody, TResponse, TParams?, TUser?, TQuery?>
GetController<TResponse, TQuery?, TParams?, TUser?>
PutController<TBody, TResponse, TParams?, TUser?, TQuery?>
PatchController<TBody, TResponse, TParams?, TUser?, TQuery?>
DeleteController<TParams?, TUser?>
// Interface implementations
RabApiPost<T> | AtomApiPost<T>
RabApiGet<T> | AtomApiGet<T>
RabApiPut<T> | AtomApiPut<T>
RabApiPatch<T> | AtomApiPatch<T>
RabApiDelete<T> | AtomApiDelete<T>Route Options
interface RouteOptions {
bodySchema?: Joi.ObjectSchema; // Body validation
querySchema?: Joi.ObjectSchema; // Query validation
isProtected?: boolean; // JWT required (default: true)
permission?: string; // Permission name
pipes?: Function[]; // Middleware
excludeFromDocs?: boolean; // Hide from OpenAPI
tags?: string[]; // OpenAPI tags
}Complete Example
import { Get, Post, Put, Delete, RabApi, Injectable } from 'rab-api';
import * as Joi from 'joi';
// Service
@Injectable()
class ProductService {
async findAll() { return []; }
async create(data: any) { return { id: '1', ...data }; }
}
// Controllers
const createSchema = Joi.object({
name: Joi.string().required(),
price: Joi.number().required(),
});
@Get('/')
class ListProducts {
constructor(private service: ProductService) {}
handler = async () => await this.service.findAll();
}
@Post('/', { bodySchema: createSchema })
class CreateProduct {
constructor(private service: ProductService) {}
handler = async (req) => await this.service.create(req.body);
}
// App
const app = RabApi.createApp({
auth: { jwt: { secret_key: 'secret', algorithms: ['HS256'] } },
});
app.use(express.json());
app.route({
basePath: '/products',
controllers: [ListProducts, CreateProduct],
});
app.listen(3000);License
MIT © Softin Hub
Support
Email: [email protected]
