A TypeScript REST API framework built on Express.js with decorator-based routing, dependency injection, and built-in validation
npm install @rabstack/rab-apiA TypeScript REST API framework built on Express.js with decorator-based routing, dependency injection, and built-in validation.
- 🎯 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
``bash`
npm install rab-api
Peer dependencies:
`bash`
npm install express joi typedi reflect-metadata jsonwebtoken compose-middleware
1. Create a controller:
`typescript
import { Get, RabApiGet, GetController } from 'rab-api';
type ControllerT = GetController<{ status: string }>;
@Get('/health')
export class HealthCheck implements RabApiGet
handler: ControllerT['request'] = async () => {
return { status: 'ok' };
};
}
`
2. Bootstrap your app:
`typescript
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);
`
Controllers handle HTTP requests using decorators:
`typescript
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
const schema = Joi.object({
name: Joi.string().required(),
email: Joi.string().email().required(),
});
@Post('/users', { bodySchema: schema })
export class CreateUser implements RabApiPost
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
Controllers support constructor injection via TypeDI:
`typescript
import { Injectable } from 'rab-api';
@Injectable()
class UserService {
async findAll() {
return [];
}
}
@Get('/users')
export class ListUsers implements RabApiGet
constructor(private userService: UserService) {}
handler: ControllerT['request'] = async () => {
return await this.userService.findAll();
};
}
`
Group related controllers with routers:
`typescript
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],
}),
],
});
`
`typescript
const createProductSchema = Joi.object({
name: Joi.string().min(3).required(),
price: Joi.number().positive().required(),
});
@Post('/products', { bodySchema: createProductSchema })
export class CreateProduct implements RabApiPost
handler: ControllerT['request'] = async (request) => {
const { name, price } = request.body; // validated
return { id: '1', name, price };
};
}
`
`typescript
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
handler: ControllerT['request'] = async (request) => {
const { page, limit } = request.query; // validated
return { items: [], page, limit };
};
}
`
`typescript`
const app = RabApi.createApp({
auth: {
jwt: {
secret_key: process.env.JWT_SECRET!,
algorithms: ['HS256'],
},
},
});
Routes are protected by default. Make a route public:
`typescript`
@Post('/auth/login', { isProtected: false })
export class Login implements RabApiPost
// Public endpoint
}
Access authenticated user:
`typescript`
@Get('/profile')
export class GetProfile implements RabApiGet
handler: ControllerT['request'] = async (request) => {
const user = request.auth; // JWT payload
return { userId: user.userId };
};
}
Use permission-based access control:
`typescript`
@Post('/admin/users', { permission: 'canCreateUser' })
export class CreateUser implements RabApiPost
// Only users with 'canCreateUser' permission
}
Integrate with @softin/rab-access:
`typescript
import { Rab } from '@softin/rab-access';
const permissions = Rab.schema({
canCreateUser: [Rab.grant('admin'), Rab.grant('superAdmin')],
canDeleteUser: [Rab.grant('superAdmin')],
});
`
Built-in exceptions:
`typescript
import {
BadRequestException,
UnauthorizedException,
ForbiddenException,
NotFoundException,
ConflictException,
} from 'rab-api';
@Get('/users/:id')
export class GetUser implements RabApiGet
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:
`typescript`
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' });
},
});
Apply middleware at different levels:
`typescript
// 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: [/ ... /],
});
`
Built-in response caching with cache invalidation (purge) support.
`typescript`
const app = RabApi.createApp({
cache: {
adapter: myCacheAdapter, // Implement ICacheAdapter
defaultTtl: 900, // 15 minutes default
},
});
`typescript`
interface ICacheAdapter {
get
set
del(key: string): Promise
}
`typescript
// 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 {}
`
- url-query (default): Cache key includes path + sorted query params
- /products?page=1&limit=10 → key: /products?limit=10&page=1url-params
- : Cache key is just the resolved path/products?page=1&limit=10
- → key: /products
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
1. Extract the path: The resolved path (with route params filled in) is extracted from the request
2. Apply strategy: Based on the configured strategy, query parameters may be included
3. 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 alphabetically
`
#### Why Sorting Matters
Query parameters are sorted alphabetically to ensure the same cache key regardless of parameter order:
`typescript`
// These requests produce the SAME cache key:
GET /products?limit=10&page=1
GET /products?page=1&limit=10
// Both → /products?limit=10&page=1
#### URL Encoding for Safety
Special characters in query values are URL-encoded to prevent cache key collisions:
`typescript`
// 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%3D2
#### Strategy 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 |
Purge (invalidate) cache keys after mutations to keep cached data fresh.
#### How Purge Works
1. After successful response: Purge runs only after the handler returns successfully
2. Pattern resolution: :param placeholders are replaced with actual request param values
3. Background execution: Purge operations run asynchronously (non-blocking)
4. Silent failures: Purge errors are caught and ignored to avoid breaking the main response
#### Purge Patterns
Static patterns - Exact cache keys to invalidate:
`typescript`
@Post('/products', {
cache: {
purge: ['/products'], // Purge the list endpoint
}
})
export class CreateProduct {}
Dynamic patterns - Use :param placeholders resolved from request params:
`typescript`
@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:
`typescript/products/${req.params.id}
@Delete('/products/:id', {
cache: {
purge: [
(req) => ,/categories/${req.body.categoryId}/products
(req) => , // Access body/products/${req.params.id}
(req) => [ // Return multiple keys
,/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 immediately
`
#### Common Purge Patterns
`typescript
// 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/products/${i.productId}/stock
(req) => req.body.items.map(i => ),`
]
}
})
`typescript
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 },
});
`
` // Interface implementations ` ` // Service // Controllers @Get('/') @Post('/', { bodySchema: createSchema }) // App app.use(express.json()); app.listen(3000); MIT © Softin Hubtypescript
// Type helpers for controllers
PostController
GetController
PutController
PatchController
DeleteController
RabApiPost
RabApiGet
RabApiPut
RabApiPatch
RabApiDelete
`Route Options
typescript`
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
typescript
import { Get, Post, Put, Delete, RabApi, Injectable } from 'rab-api';
import * as Joi from 'joi';
@Injectable()
class ProductService {
async findAll() { return []; }
async create(data: any) { return { id: '1', ...data }; }
}
const createSchema = Joi.object({
name: Joi.string().required(),
price: Joi.number().required(),
});
class ListProducts {
constructor(private service: ProductService) {}
handler = async () => await this.service.findAll();
}
class CreateProduct {
constructor(private service: ProductService) {}
handler = async (req) => await this.service.create(req.body);
}
const app = RabApi.createApp({
auth: { jwt: { secret_key: 'secret', algorithms: ['HS256'] } },
});
app.route({
basePath: '/products',
controllers: [ListProducts, CreateProduct],
});
``License
Support
Email: softin.developer@gmail.comDist Tags
next1.8.2-next.7latest1.12.0