A package which supplements NestJS applications with out-of-the-box CRUD endpoints, rich query parameters, and ergonomic glue for Fundering.
When building NestJS APIs you usually find yourself writing the same CRUD plumbing for every module. nest-utilities removes that boilerplate by giving you:
- An abstract
CrudControllerthat wraps afunderingCrudServiceand exposes ready-to-go create / read / update / delete handlers. - A
@QueryOptions()parameter decorator that turns HTTP query strings (or a JSON header) into Mongoose-friendly options — including filtering, sorting, pagination, projection, population and aggregation$addFields. - An
HttpExceptionFilterthat produces consistent error payloads. - A handful of small interfaces, constants and utilities that make the above safe and predictable.
The package is intentionally thin: persistence, hooks and authorisation logic live in fundering; nest-utilities is the HTTP-side adapter on top of it.
npm install nest-utilities funderingnest-utilities ships TypeScript types out of the box. The peer dependencies are:
| Package | Version |
|---|---|
@nestjs/common |
>= 8.0.0 |
@nestjs/core |
>= 8.0.0 |
fundering |
>= 1.3.0 |
mongoose |
>= 5.12.0 |
rxjs |
>= 7.1.0 |
Make sure those are already installed in your NestJS project.
The example below wires up a fully functional /users resource with filtering, sorting, pagination and population, while still letting you customise individual endpoints when needed.
// user.schema.ts
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Document } from "mongoose";
@Schema()
export class User extends Document {
@Prop() firstName: string;
@Prop() lastName: string;
@Prop() email: string;
}
export const UserSchema = SchemaFactory.createForClass(User);// user.service.ts
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { CrudService } from "fundering";
import { Model } from "mongoose";
import { User } from "./user.schema";
@Injectable()
export class UserService extends CrudService<User> {
constructor(@InjectModel(User.name) userModel: Model<User>) {
super(userModel);
}
}// user.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
} from "@nestjs/common";
import {
CrudController,
IExpressQueryOptions,
QueryOptions,
} from "nest-utilities";
import { User } from "./user.schema";
import { UserService } from "./user.service";
@Controller("users")
export class UserController extends CrudController<User> {
constructor(userService: UserService) {
super(userService);
}
@Post()
create(@Body() user: User, @QueryOptions() options: IExpressQueryOptions) {
return this.handleCreate(user, options);
}
@Get()
find(@QueryOptions() options: IExpressQueryOptions) {
return this.handleFind(options.match ?? {}, options);
}
@Get(":id")
findById(
@Param("id") id: string,
@QueryOptions() options: IExpressQueryOptions,
) {
return this.handleFindById(id, options);
}
@Put(":id")
put(
@Param("id") id: string,
@Body() user: User,
@QueryOptions() options: IExpressQueryOptions,
) {
return this.handlePut(user, id, options);
}
@Patch(":id")
patch(
@Param("id") id: string,
@Body() user: Partial<User>,
@QueryOptions() options: IExpressQueryOptions,
) {
return this.handlePatch(user, id, options);
}
@Delete(":id")
remove(
@Param("id") id: string,
@QueryOptions() options: IExpressQueryOptions,
) {
return this.handleDelete(id, options);
}
}That's it. The endpoints now accept the query parameters listed below, automatically expose X-Total-Count when asked for, and route every error through Nest's standard exception flow.
Abstract class you extend in your own controller. It receives a fundering CrudService in its constructor and exposes the following handlers — none of them register routes for you, you wire them up with the usual Nest decorators (@Get, @Post, etc.). All handlers accept an IExpressQueryOptions argument so authorisation hooks inside fundering get access to the original Request and Response.
| Handler | Signature | Notes |
|---|---|---|
handleCreate |
(model, options?) => Promise<ModelType> |
Wraps crudService.create. |
handleCreateMany |
(models, options?) => Promise<ModelType[]> |
Wraps crudService.createMany. |
handleFind |
(conditions, options?) => Promise<ModelType[]> |
If options.includeCount is true, calls crudService.count first so the X-Total-Count header gets populated. |
handleFindById |
(id, options?) => Promise<ModelType> |
Throws NotFoundException("No model found") if the document is not visible to the requester. |
handlePut |
(model, id?, options?) => Promise<ModelType> |
Validates visibility via handleFindById before replacing the document. |
handlePutMany |
(models, options?) => Promise<ModelType[]> |
Validates visibility for each model, then replaces them in bulk. |
handlePatch |
(partial, id?, options?) => Promise<ModelType> |
Validates visibility, then merges (crudService.mergeModel). |
handlePatchMany |
(partials, options?) => Promise<ModelType[]> |
Validates visibility for each, then bulk merges. |
handleDelete |
(id, options?) => Promise<ModelType | null> |
Validates visibility, then removes via crudService.deleteById. |
When the constructor runs, an addCountHeaderHook is attached to the underlying service. It hooks into fundering's postCount lifecycle and sets two response headers when a count is performed:
X-Total-Count: <number>Access-Control-Expose-Headers: X-Total-Count, ...(existing values are preserved)
A NestJS parameter decorator that converts the incoming HTTP request into an IExpressQueryOptions object. Two transport modes are supported:
- Query string — the default. Use bracket notation for nested keys, e.g.
?match[firstName]=John&limit=10. - Header — set
x-query-optionsto a JSON-encodedIHttpOptionsobject. Useful when you would exceed URL length limits or want to send deeply nested filters cleanly.
The decorator accepts an optional IQueryOptionsConfig:
@Get()
find(
@QueryOptions({ maxDepth: 5 }) options: IExpressQueryOptions,
) { ... }maxDepth (default 3) caps how deeply the parser will recurse into match filters and populate paths to keep accidental N+1 explosions in check.
The returned object is the parsed IQueryOptions plus the original request and response, so authorisation hooks downstream in fundering can inspect them.
A @Catch()-all filter that normalises errors into JSON. Pass showErrorDetails in the constructor to control whether stack traces and original messages leak to the client (typical pattern: process.env.NODE_ENV !== "production").
// main.ts
import { HttpExceptionFilter } from "nest-utilities";
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(
new HttpExceptionFilter(process.env.NODE_ENV !== "production"),
);Behaviour:
- Instances of
HttpExceptionare forwarded with their original status and body. - Anything else becomes
{ status: 500, message: "Internal server error" }. WhenshowErrorDetailsistrue, the response also containsmessage(the original error message) andstack.
// re-exported from the package root
import {
IModel,
IExpressQueryOptions,
IHttpOptions,
IQueryOptionsConfig,
} from "nest-utilities";IModel—{ id?: any; _id?: any }. The minimum shapeCrudControllerrequires from your model type.IExpressQueryOptions— extendsfundering'sIQueryOptionswithrequest,responseandincludeCount. This is what@QueryOptions()injects.IHttpOptions— describes the raw HTTP query string shape (the thing the user sends; see Query parameters).IQueryOptionsConfig—{ maxDepth?: number }, accepted by@QueryOptions().
nest-utilities exposes one custom MongoDB-style operator out of the box:
$isNull—?match[deletedAt][$isNull]=truebecomes{ deletedAt: { $eq: null } }. Truthy values (true,"true",1,"1") check fornull; everything else inverts to{ $ne: null }.
The operator table is exported as customOperators from the package root if you want to extend it in your own utilities.
queryToOptions(query, config?)— the parsing function used internally by@QueryOptions(). Useful in tests or when you want the same parsing behaviour outside of a request context.addCountHeaderHook(service)— attaches theX-Total-Countresponse hook to aCrudService.CrudControllercalls this for you, but you can call it directly if you bypass the controller abstraction.
Every handler that goes through @QueryOptions() accepts the parameters below. They map to stages of the MongoDB aggregation pipeline.
| Param | Type | Description |
|---|---|---|
match |
Record<string, any> |
$match filter. Supports MongoDB operators ($in, $gt, $regex, …) and the $isNull extension. |
sort |
string[] |
$sort. Prefix a field with - for descending: ?sort=-createdAt&sort=lastName. |
skip |
number |
$skip. Use for pagination. |
offset |
number (deprecated) |
Alias for skip. Will be removed in a future major. |
limit |
number |
$limit. |
select |
string[] |
$project. Dot notation supported: ?select=name&select=address.city. |
populate |
(string | IPopulateOptions)[] |
Mongoose-style populate. Strings allow dot notation; objects allow match/sort/limit per join. |
addFields |
Record<string, any> |
$addFields stage; usable in subsequent match/sort. |
distinct |
string | string[] |
Returns unique values for the given field(s). |
random |
boolean |
When truthy, returns documents in random order (overrules sort). |
includeCount |
boolean |
When true, the response sets X-Total-Count to the unpaginated match count. |
custom |
Record<string, any> |
Free-form bag forwarded to fundering hooks for application-specific behaviour. |
Primitive coercion. By default every query string value is a string. Wrap a value in {{ }} to coerce it: {{42}}, {{true}}, {{null}}, {{undefined}}. This is the canonical way to do ?match[isPublished]={{true}} instead of receiving the literal string "true".
Depth limits. match keys deeper than maxDepth (default 3) are silently dropped, and populate paths are truncated. Bump maxDepth per-decorator if you need more.
Header transport. For complex queries, send a single x-query-options header containing a JSON-stringified IHttpOptions:
GET /users
x-query-options: {"match":{"firstName":{"$in":["John","Jane"]}},"limit":10}Tell the server you want the count, then read it from the response header on the client.
GET /users?limit=20&skip=40&includeCount=trueThe response includes X-Total-Count: 137. The header is also added to Access-Control-Expose-Headers so browsers can read it cross-origin.
GET /users?match[firstName][$regex]=^Jo&sort=-createdAt&sort=lastNameUse the header transport for nested options:
{
"populate": [
{
"path": "school",
"match": { "active": "{{true}}" },
"populate": [{ "path": "students", "limit": 5 }]
}
]
}GET /users?select=firstName&select=lastName&select=address.cityGET /users?distinct=emailGET /users?match[deletedAt][$isNull]=trueGET /quotes?limit=5&random=true@Get()
find(
@QueryOptions({ maxDepth: 6 }) options: IExpressQueryOptions,
) {
return this.handleFind(options.match ?? {}, options);
}// main.ts
app.useGlobalFilters(
new HttpExceptionFilter(process.env.NODE_ENV !== "production"),
);In production, all unhandled errors are masked behind a 500 Internal server error. In development you get the original message and stack to debug with.
addCountHeaderHook works on any CrudService instance, so you can keep using the same response-header behaviour even when you call services directly from a non-CRUD controller:
import { addCountHeaderHook } from "nest-utilities";
constructor(private userService: UserService) {
addCountHeaderHook(this.userService);
}Built and tested against:
- NestJS
>= 8 - Mongoose
>= 5.12 - Fundering
>= 1.3 - RxJS
>= 7.1
The package is published as CommonJS (distribution/index.js) with bundled type declarations (distribution/index.d.ts).
ISC © MartinDrost
For deeper guides and migration notes, see the wiki and the issue tracker.