Restools - Simple tools for NestJS framework and REST APIs
Utilities for NestJS REST APIs: abstract base classes, validation pipes, query-string filters, request context, and optional Mongoose CRUD helpers.
The package is split into a core entry point and optional submodules. Optional modules are not re-exported from the main entry so consumers are not forced to install mongoose or other peer dependencies.
| Module | Source folder | Import path | Extra dependencies |
|---|---|---|---|
| Core (abstracts, pipes, cluster) | src/_abstracts, _pipes, _services |
@tacxou/nestjs_module_restools |
@nestjs/common, @nestjs/core |
| Request context | src/request-context |
@tacxou/nestjs_module_restools/dist/request-context |
— |
| Search filter schema | src/search-filter-schema |
@tacxou/nestjs_module_restools/dist/search-filter-schema |
mongoose (for ObjectId filters) |
| Mongoose | src/mongoose |
@tacxou/nestjs_module_restools/dist/mongoose |
mongoose, @nestjs/mongoose, @nestjs/event-emitter |
| Real IP decorator | src/real-ip |
@tacxou/nestjs_module_restools/dist/real-ip |
request-ip |
Internal utilities under src/_utils (e.g. memoize.util.ts) are not published.
yarn add @tacxou/nestjs_module_restools
# peers: @nestjs/common, @nestjs/core# Request context (AsyncLocalStorage for req/res in services)
# No extra packages beyond NestJS
# Search filters (query string → MongoDB-style filters)
yarn add mongoose
# Mongoose CRUD service + validation
yarn add mongoose @nestjs/mongoose @nestjs/event-emitter
# RealIp decorator (client IP from request headers)
yarn add request-ip
# import from @tacxou/nestjs_module_restools/dist/real-ipAfter yarn build, optional modules are consumed from dist/<module> (see import examples below).
Import from the package root:
import {
AbstractService,
AbstractController,
DtoValidationPipe,
AppClusterService,
} from '@tacxou/nestjs_module_restools'Base classes with logging and naming helpers.
AbstractService
logger— NestLoggernamed after the service.request— HTTP request from constructor context orRequestContextStorage(if request-context module is used).eventEmitter— optionalEventEmitter2from context.moduleName— derived fromrequest.path(first segment, capitalized) orcontext.moduleName.serviceName— class name withoutServicesuffix, orcontext.serviceName.
AbstractController
logger,moduleRef,controllerName(same naming pattern as services).
Context injection
import { Injectable } from '@nestjs/common'
import { ModuleRef } from '@nestjs/core'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { AbstractService, AbstractServiceContext } from '@tacxou/nestjs_module_restools'
@Injectable()
export class OrderService extends AbstractService {
constructor(moduleRef: ModuleRef, eventEmitter: EventEmitter2) {
super({
moduleRef,
eventEmitter,
moduleName: 'Orders', // optional override
serviceName: 'Order', // optional override
} as AbstractServiceContext)
}
doWork() {
const user = this.request?.user
this.logger.log(`Handling order for ${user?.username ?? 'anonymous'}`)
}
}Pair with RequestContextModule so this.request is available without passing req manually.
Extends Nest ValidationPipe with:
transform: trueand implicit conversion enabled by default.- Flat validation errors:
{ "field": "message", "parent.child": "message" }. - Custom
exceptionFactoryreturning{ statusCode: 400, message, validations }.
Global registration
import { Module } from '@nestjs/common'
import { APP_PIPE } from '@nestjs/core'
import { DtoValidationPipe } from '@tacxou/nestjs_module_restools'
@Module({
providers: [
{
provide: APP_PIPE,
useClass: DtoValidationPipe,
},
],
})
export class AppModule {}Per-controller
@UsePipes(DtoValidationPipe)
@Controller('users')
export class UsersController {}Runs the Nest bootstrap callback in Node cluster mode (one worker per CPU by default).
import { NestFactory } from '@nestjs/core'
import { AppClusterService } from '@tacxou/nestjs_module_restools'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
await app.listen(3000)
}
AppClusterService.clusterize(bootstrap, {
clusterize: process.env.CLUSTERIZE === 'true',
forks: 4,
name: 'MyApi',
})| Option | Default | Description |
|---|---|---|
clusterize |
— | If falsy, runs callback once (no clustering). |
forks |
os.cpus().length |
Number of worker processes. |
name |
AppClusterService |
Log label. |
Import from dist/<module> after building or from the published package layout:
import { RequestContextModule } from '@tacxou/nestjs_module_restools/dist/request-context'
import { RealIp } from '@tacxou/nestjs_module_restools/dist/real-ip'Parameter decorator that resolves the client IP via request-ip.
yarn add request-ipimport { Controller, Get } from '@nestjs/common'
import { RealIp } from '@tacxou/nestjs_module_restools/dist/real-ip'
@Controller('auth')
export class AuthController {
@Get('ping')
ping(@RealIp() ip: string | null) {
return { ip }
}
}Stores the current Express req / res in AsyncLocalStorage so any code in the same async chain can read them (e.g. AbstractService.request).
Setup
import { Module } from '@nestjs/common'
import { RequestContextModule } from '@tacxou/nestjs_module_restools/dist/request-context'
@Module({
imports: [RequestContextModule],
})
export class AppModule {}RequestContextModule applies RequestContextMiddleware to all routes (*).
Read context in a service
import { RequestContextStorage } from '@tacxou/nestjs_module_restools/dist/request-context'
const ctx = RequestContextStorage.currentContext
const req = ctx?.reqFlow: HTTP request → middleware → storage.run(context, next) → controllers / services see currentContext.
Parses query-string filters and pagination into objects usable with MongoDB (or any store).
Import:
import {
SearchFilterSchema,
SearchFilterOptions,
filterSchema,
filterOptions,
} from '@tacxou/nestjs_module_restools/dist/search-filter-schema'Syntax: filters[PREFIX + FIELD]=VALUE
| Prefix | Description |
|---|---|
: |
Equal |
# |
Number equal |
!# |
Number not equal |
!: |
Not equal |
> |
Greater than |
| `> | ` |
< |
Less than |
| `< | ` |
@ |
In |
!@ |
Not in |
@# |
Number in |
!@# |
Number not in |
^ |
Regex |
? |
Boolean (true / false / 1 / 0) |
Examples
filters[=subject]=53→{ subject: 53 }(or string, depending on field)filters[:concernedTo]=65fab2d6946a5ede152f2689→ ObjectId whenconvertObjectIdis enabledfilters[^sequence]=/53/→ regex onsequence
Controller usage
import { Controller, Get } from '@nestjs/common'
import {
SearchFilterSchema,
SearchFilterOptions,
} from '@tacxou/nestjs_module_restools/dist/search-filter-schema'
@Controller('search')
export class SearchController {
@Get()
search(
@SearchFilterSchema() filters: Record<string, unknown>,
@SearchFilterOptions() options: { limit: number; skip: number; sort: Record<string, 1 | -1> },
) {
return { filters, options }
}
}curl example
curl --request GET \
--url 'http://localhost/search?limit=9999&filters%5B%5Esequence%5D=%2F53%2F&sort%5Bmetadata.createdAt%5D=-1&sort%5Bsubject%5D=1'
# limit=9999
# filters[^sequence]=/53/
# sort[metadata.createdAt]=-1
# sort[subject]=1Decorator options (see source): queryKey, strict, unsafe, convertObjectId, convertNull, allowedFilters, etc.
Reads pagination and sort from the query string:
| Query key | Default | Role |
|---|---|---|
limit |
10 |
Page size (allowUnlimited for no cap) |
skip / page |
0 |
Offset (if both skip and page are set, skip is ignored) |
sort[field] |
{} |
1 / -1 or asc / desc |
Typed CRUD base service, route pipes, and exception filter for Mongoose 9.
Import:
import {
AbstractServiceSchema,
ServiceSchemaInterface,
EventEmitterSeparator,
ObjectIdValidationPipe,
MongooseValidationFilter,
} from '@tacxou/nestjs_module_restools/dist/mongoose'Dependencies: mongoose, @nestjs/mongoose, @nestjs/event-emitter, and core AbstractService (via extends).
Generic class-level types (no per-method <T> shadowing):
TRawDoc— plain document interface (_id?: Types.ObjectId, fields…).THydrated— defaults toHydratedDocument<TRawDoc>.
CRUD methods (return hydrated documents, not Mongoose Query):
| Method | Returns |
|---|---|
find(filter?, projection?, options?) |
Promise<THydrated[]> |
findOne(filter?, …) |
Promise<THydrated> (throws NotFoundException) |
findById(id, …) |
Promise<THydrated> |
count(filter?, options?) |
Promise<number> |
findAndCount(filter?, …) |
Promise<[THydrated[], number]> |
create(data?, options?) |
Promise<THydrated> |
update(id, update, options?) |
Promise<THydrated> |
upsert(filter, update, options?) |
Promise<THydrated> |
delete(id, options?) |
Promise<THydrated> |
Example service
import { Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { ModuleRef } from '@nestjs/core'
import { HydratedDocument, Model, Types } from 'mongoose'
import { AbstractServiceSchema } from '@tacxou/nestjs_module_restools/dist/mongoose'
interface IUser {
_id?: Types.ObjectId
email: string
name: string
}
type HydratedUser = HydratedDocument<IUser>
@Injectable()
export class UserService extends AbstractServiceSchema<IUser, HydratedUser> {
protected readonly model: Model<IUser, {}, {}, {}, HydratedUser>
constructor(
@InjectModel('User') model: Model<IUser, {}, {}, {}, HydratedUser>,
moduleRef: ModuleRef,
eventEmitter: EventEmitter2,
) {
super({ moduleRef, eventEmitter })
this.model = model
}
}Use RequestContextModule so this.request and this.moduleName work inside hooks without passing req in the constructor.
When eventEmitter is set on the service context, each operation emits async hooks:
Event name pattern
{moduleName}.{serviceName}.service.{before|after}{Operation}
Example: users.user.service.beforeFind (segments lowercased; separator is . — EventEmitterSeparator).
Before hooks — listeners can return partial overrides merged into the operation:
| Key | Effect |
|---|---|
filter |
Merged into query filter |
projection / options |
Merged into Mongoose options |
data |
Merged into create payload |
update |
Merged into update query |
_id |
Overrides id for findById / update / delete |
stop |
If set, thrown immediately (abort operation) |
After hooks — listeners can return data, created, updated, deleted, result, or adjust count on findAndCount.
import { OnEvent } from '@nestjs/event-emitter'
@Injectable()
export class UserHooksListener {
@OnEvent('users.user.service.beforeFind')
beforeFind(payload: { filter?: Record<string, unknown>; eventName: string }) {
return { filter: { deleted: { $ne: true } } }
}
}Validates route/param strings and returns Types.ObjectId.
import { Controller, Get, Param } from '@nestjs/common'
import { Types } from 'mongoose'
import { ObjectIdValidationPipe } from '@tacxou/nestjs_module_restools/dist/mongoose'
@Controller('users')
export class UsersController {
@Get(':id')
findOne(@Param('id', ObjectIdValidationPipe) id: Types.ObjectId) {
return { id: id.toString() }
}
}Factory for an exception filter that maps Mongoose ValidationError and CastError to HTTP 406 with a validations map.
import { APP_FILTER } from '@nestjs/core'
import { MongooseValidationFilter } from '@tacxou/nestjs_module_restools/dist/mongoose'
@Module({
providers: [
{
provide: APP_FILTER,
useClass: MongooseValidationFilter(),
},
],
})
export class AppModule {}Pass custom exception types: MongooseValidationFilter([MyCustomError]).
Debug: add ?debug=1 to the query in non-production to include _exception in the response body.
From the repository root:
yarn install
yarn build # compile to dist/
yarn test # Jest (*.spec.ts)
yarn test:types # TypeScript checks for mongoose typetest
yarn test:cov # coverageSource layout mirrors dist/:
src/
index.ts # core exports only
_abstracts/
_pipes/
_services/
real-ip/
request-context/
search-filter-schema/
mongoose/
MIT — see LICENSE.