@ciphercross/nestjs-contracts
v1.0.2
Published
Standardized API contracts for NestJS applications
Readme
@ciphercross/nestjs-contracts
Standardized API contracts for NestJS applications. Provides consistent response formats, request ID tracking, pagination metadata, and Swagger integration.
Table of Contents
Installation
npm install @ciphercross/nestjs-contractsPeer Dependencies:
@nestjs/common^10.0.0@nestjs/swagger^7.0.0
Quick Start
Method 1: Using ContractsModule (Recommended)
The easiest way - use ContractsModule.forRoot() which automatically configures everything:
In app.module.ts:
import { Module } from '@nestjs/common';
import { ContractsModule } from '@ciphercross/nestjs-contracts';
@Module({
imports: [
ContractsModule.forRoot({
requestIdHeader: 'x-request-id',
defaultPagination: { limit: 20, maxLimit: 100, maxPage: 1000 },
enableRequestContext: true, // Optional: for accessing requestId from services
extractUserId: (req) => req.user?.id, // Optional: custom userId extraction
}),
// ... other modules
],
})
export class AppModule {}Benefits:
- Automatic registration of middleware and interceptor
- Centralized configuration
- Less human error
- Access to configuration via
ContractsConfigService
Method 2: Manual Setup (Legacy)
If you need more control, you can configure manually:
In main.ts:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { requestIdMiddleware, ResponseWrapInterceptor } from '@ciphercross/nestjs-contracts';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Add request ID middleware (must be first)
app.use(requestIdMiddleware);
// Wrap all successful responses
app.useGlobalInterceptors(new ResponseWrapInterceptor());
await app.listen(3000);
}
bootstrap();Important: Middleware must be added before interceptors.
2. Use in Controllers
Simple Response
import { Controller, Get } from '@nestjs/common';
import { ApiOkContract } from '@ciphercross/nestjs-contracts';
import { UserDto } from './user.dto';
@Controller('users')
export class UsersController {
@Get(':id')
@ApiOkContract(UserDto)
async findOne(@Param('id') id: string) {
return this.usersService.findOne(id); // Returns UserDto, wrapped automatically
}
}Paginated Response (Automatic)
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOkPaginatedContract } from '@ciphercross/nestjs-contracts';
import { UserDto } from './user.dto';
@Controller('users')
export class UsersController {
@Get()
@ApiOkPaginatedContract(UserDto) // Explicitly indicates pagination
async list(@Query() query: { page?: number; limit?: number }) {
// Just return { items, total, page?, limit? } - interceptor handles it automatically
return this.usersService.findAll({
page: query.page ?? 1,
limit: query.limit ?? 20,
});
}
}Paginated Response (Manual with @Req())
import { Controller, Get, Query, Req } from '@nestjs/common';
import { ApiOkContract, buildPaginationMeta } from '@ciphercross/nestjs-contracts';
import { UserDto } from './user.dto';
@Controller('users')
export class UsersController {
@Get()
@ApiOkContract(UserDto, true) // true = array response
async list(@Query() query: ListUsersDto, @Req() req: any) {
const { items, total } = await this.usersService.findAll(query);
// Set pagination meta (will be included in response)
req.paginationMeta = buildPaginationMeta({
page: query.page ?? 1,
limit: query.limit ?? 20,
total,
});
return items; // Just return data, interceptor wraps it
}
}Skip Wrapping (for file downloads, streaming, etc.)
import { Controller, Get, Res } from '@nestjs/common';
import { SkipContract } from '@ciphercross/nestjs-contracts';
import { Response } from 'express';
@Controller('files')
export class FilesController {
@SkipContract()
@Get('download/:id')
downloadFile(@Param('id') id: string, @Res() res: Response) {
// Response won't be wrapped
return res.sendFile(`/path/to/file-${id}.pdf`);
}
}Features
✅ Standardized Response Format
All successful responses follow this structure:
{
"success": true,
"data": { ... },
"meta": {
"timestamp": "2026-01-12T13:00:00.000Z",
"requestId": "req_abc123",
"pagination": {
"page": 1,
"limit": 20,
"total": 145,
"totalPages": 8
}
}
}✅ Request ID Tracking
- Reads
x-request-idheader if present - Generates unique ID if missing:
req_<24 hex chars> - Sets in request object for use by interceptors/filters
- Returns in response headers for debugging
✅ Automatic Response Wrapping
Controllers return only data - interceptor automatically wraps with:
success: truedata: <your data>meta: { timestamp, requestId, pagination? }
Automatic Pagination Detection:
- If controller returns
{ items, total, page?, limit? }, pagination is handled automatically - No need for separate
PaginatedResponseInterceptoror@Req()injection - Values are automatically clamped to
maxLimitandmaxPagefrom configuration
✅ Swagger Integration
Decorators automatically generate correct Swagger schemas:
@ApiOkContract(UserDto) // Single item
@ApiOkContract(UserDto, true) // Array of items (pagination optional)
@ApiOkPaginatedContract(UserDto) // Paginated list (pagination always present)
// Error responses
@ApiBadRequestContract()
@ApiUnauthorizedContract()
@ApiForbiddenContract()
@ApiNotFoundContract()
@ApiConflictContract()
@ApiInternalErrorContract()✅ Type Exports & Unwrap Helpers for Frontend
All types are exported and can be used in frontend TypeScript projects:
import type { SuccessResponse, ErrorResponse, PaginationMeta } from '@ciphercross/nestjs-contracts';
import {
unwrapData,
unwrapOrThrow,
getPagination,
getRequestId,
getErrors,
isSuccessResponse,
isErrorResponse,
getErrorMessage,
} from '@ciphercross/nestjs-contracts';
// Option 1: Using unwrapOrThrow (throws Error if response is not successful)
// unwrapOrThrow throws Error with message formatted via getErrorMessage()
const response = await fetch('/api/users');
const data = await response.json();
try {
const users = unwrapOrThrow(data);
const pagination = getPagination({ ...data, data: users });
} catch (error) {
console.error(error.message); // message formatted via getErrorMessage()
}
// Option 2: Using if-else checks
if (isSuccessResponse(data)) {
const users = unwrapData(data);
const pagination = getPagination(data);
} else {
const message = getErrorMessage(data);
const errors = getErrors(data);
}✅ RequestContext (AsyncLocalStorage)
Access requestId and userId from anywhere in your application (services, repositories) without passing req:
import { RequestContext } from '@ciphercross/nestjs-contracts';
@Injectable()
export class UsersService {
async findAll() {
const requestId = RequestContext.getRequestId();
const userId = RequestContext.getUserId();
this.logger.log(`[${requestId}] Processing for user ${userId}`);
}
}API Reference
Types
SuccessResponse<T>
Type for successful API responses.
type SuccessResponse<T> = {
success: true;
data: T;
meta: ResponseMeta;
};ErrorResponse
Type for error responses (used by @ciphercross/nestjs-errors).
type ErrorResponse = {
success: false;
message: string;
errors: ApiError[];
meta: ResponseMeta;
};ResponseMeta
Metadata included in all responses.
type ResponseMeta = {
timestamp: string; // ISO string
requestId: string;
pagination?: PaginationMeta;
};PaginationMeta
Pagination information.
type PaginationMeta = {
page: number;
limit: number;
total: number;
totalPages: number;
/**
* Optional clamp information for debugging
* Indicates if values were automatically clamped to limits
*/
clamped?: {
page?: boolean;
limit?: boolean;
};
};PaginatedResult<T>
Official type for paginated results that ResponseWrapInterceptor recognizes.
export type PaginatedResult<T> = {
items: T[];
total: number;
page?: number;
limit?: number;
};Important: This is the only format that the interceptor automatically recognizes and processes. If your service returns an object with this structure, pagination will be handled automatically.
Priority for page/limit:
result.page/result.limit(if service returned)req.query.page/req.query.limit(if present in query params)config.defaultPagination.limit+page=1(default)
ApiError
Error detail structure.
type ApiError = {
code: string;
message: string;
field?: string;
details?: unknown;
};Enums
SortOrder
enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}TimeFilter
enum TimeFilter {
ALL = 'ALL',
TODAY = 'TODAY',
LAST_7_DAYS = 'LAST_7_DAYS',
LAST_30_DAYS = 'LAST_30_DAYS',
EARLIER = 'EARLIER',
}StatusFilter
enum StatusFilter {
ALL = 'ALL',
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
}Middleware
requestIdMiddleware
Extracts or generates request ID from x-request-id header.
Usage:
app.use(requestIdMiddleware);Behavior:
- Reads
x-request-idheader (max 200 chars) - Generates new ID if missing:
req_<24 hex chars> - Sets
req.requestIdfor use by interceptors/filters - Returns ID in response headers
Interceptors
ResponseWrapInterceptor
Automatically wraps controller responses with success format.
Usage:
app.useGlobalInterceptors(new ResponseWrapInterceptor());Behavior:
- Wraps response with
{ success: true, data, meta } - Adds timestamp (ISO format)
- Adds requestId from middleware
- Automatically detects pagination - if controller returns
{ items, total, page?, limit? }(typePaginatedResult<T>), handles it automatically - Includes pagination meta if
req.paginationMetais set or detected automatically - Skips wrapping for endpoints with
@SkipContract()decorator - Skips wrapping for file downloads, streaming, redirects (auto-detected)
Skip wrapping decision order:
@SkipContract()decorator on handler or classskipWrappingFor(ctx)from config (if configured)res.headersSent === true(headers already sent)content-typedoesn't contain 'application/json'- 3xx redirect status codes
- Otherwise - wraps response
Utilities
buildPaginationMeta(params)
Builds pagination metadata from parameters. Automatically clamps values to limits (does not throw).
Parameters:
page: number- Current page (1-indexed, min: 1, max: 1000 by default)limit: number- Items per page (min: 1, max: 100 by default)total: number- Total items (min: 0)maxLimit?: number- Optional: override max limit (defaults to 100)maxPage?: number- Optional: override max page (defaults to 1000)
Important: If maxLimit / maxPage are not passed to buildPaginationMeta, values from ContractsModule.forRoot() are used.
Returns: PaginationMeta (may include clamped flags for debugging)
Example:
const meta = buildPaginationMeta({
page: 1,
limit: 20,
total: 145,
maxLimit: 100, // Optional override
maxPage: 1000, // Optional override
});
// { page: 1, limit: 20, total: 145, totalPages: 8 }generateRequestId()
Generates a unique request ID.
Returns: string - Format: req_<24 hex chars>
getTimestamp()
Gets current timestamp in ISO format.
Returns: string - ISO timestamp
Swagger Decorators
@ApiOkContract(model, isArray?)
Decorator for Swagger documentation of standardized responses.
Parameters:
model: Type<T>- DTO class for data typeisArray?: boolean- Whether response is array (default: false)
Example:
@ApiOkContract(UserDto) // Single user
@Get(':id')
async findOne() { ... }
@ApiOkContract(UserDto, true) // Array of users (pagination optional)
@Get()
async findAll() { ... }@ApiOkPaginatedContract(model)
Decorator for Swagger documentation of paginated responses. Explicitly indicates that meta.pagination will always be present and required in Swagger schema.
Parameters:
model: Type<T>- DTO class for data type
Example:
@ApiOkPaginatedContract(UserDto) // Paginated list
@Get()
async list() { ... }Difference from @ApiOkContract(UserDto, true):
@ApiOkContract(UserDto, true)- simple array,meta.paginationis optional in Swagger@ApiOkPaginatedContract(UserDto)- explicitly marksmeta.paginationas required in Swagger schema
Error Response Decorators
Decorators for documenting error responses in Swagger:
@ApiBadRequestContract() // 400 Bad Request
@ApiUnauthorizedContract() // 401 Unauthorized
@ApiForbiddenContract() // 403 Forbidden
@ApiNotFoundContract() // 404 Not Found
@ApiConflictContract() // 409 Conflict
@ApiInternalErrorContract() // 500 Internal Server ErrorConstants
REQUEST_ID_HEADER
Header name for request ID: 'x-request-id'
PAGINATION
Pagination constants:
DEFAULT_PAGE: 1DEFAULT_LIMIT: 20MIN_PAGE: 1MAX_PAGE: 1000(changed from 10000)MIN_LIMIT: 1MAX_LIMIT: 100(changed from 1000)
Integration Guide
Step 1: Install Package
npm install @ciphercross/nestjs-contractsStep 2: Setup (Choose One Method)
Method A: Using ContractsModule (Recommended)
In app.module.ts:
import { Module } from '@nestjs/common';
import { ContractsModule } from '@ciphercross/nestjs-contracts';
@Module({
imports: [
ContractsModule.forRoot({
defaultPagination: { limit: 20, maxLimit: 100, maxPage: 1000 },
enableRequestContext: true,
extractUserId: (req) => req.user?.id,
}),
],
})
export class AppModule {}Method B: Manual Setup
In main.ts:
import { requestIdMiddleware, ResponseWrapInterceptor } from '@ciphercross/nestjs-contracts';
app.use(requestIdMiddleware);
app.useGlobalInterceptors(new ResponseWrapInterceptor());Important: Middleware must be added before interceptors.
Step 3: Use in Controllers
Simple Response
@Get(':id')
@ApiOkContract(UserDto)
async findOne(@Param('id') id: string) {
return this.service.findOne(id); // Automatically wrapped
}Paginated Response (Automatic - Recommended)
@Get()
@ApiOkPaginatedContract(UserDto) // Explicitly indicates pagination
async list(@Query() query: { page?: number; limit?: number }) {
// Just return { items, total, page?, limit? } - interceptor handles it
return this.service.findAll({
page: query.page ?? 1,
limit: query.limit ?? 20,
});
}Paginated Response (Manual with @Req())
@Get()
@ApiOkContract(UserDto, true)
async list(@Query() query: ListDto, @Req() req: any) {
const { items, total } = await this.service.findAll(query);
req.paginationMeta = buildPaginationMeta({
page: query.page ?? 1,
limit: query.limit ?? 20,
total,
});
return items; // Pagination meta added automatically
}Step 4: Frontend Integration
Install in frontend project:
npm install @ciphercross/nestjs-contractsUse types and helpers:
import type { SuccessResponse, PaginationMeta } from '@ciphercross/nestjs-contracts';
import {
unwrapData,
getPagination,
getRequestId,
isSuccessResponse,
isErrorResponse,
getErrorMessage,
} from '@ciphercross/nestjs-contracts';
async function fetchUsers() {
const response = await fetch('/api/users');
const data = await response.json();
if (isSuccessResponse(data)) {
const users = unwrapData(data);
const pagination = getPagination(data);
const requestId = getRequestId(data);
return { users, pagination, requestId };
} else {
const message = getErrorMessage(data);
throw new Error(message);
}
}Examples
Example 1: Simple GET Endpoint
@Controller('products')
export class ProductsController {
@Get(':id')
@ApiOkContract(ProductDto)
async getProduct(@Param('id') id: string) {
return this.productsService.findOne(id);
}
}Response:
{
"success": true,
"data": {
"id": "123",
"name": "Product Name",
"price": 99.99
},
"meta": {
"timestamp": "2026-01-12T13:00:00.000Z",
"requestId": "req_abc123"
}
}Example 2: Paginated List (Automatic)
@Controller('users')
export class UsersController {
@Get()
@ApiOkPaginatedContract(UserDto)
async listUsers(@Query() query: { page?: number; limit?: number }) {
// Just return { items, total, page?, limit? } - no @Req() needed!
return this.usersService.findAll({
page: query.page ?? 1,
limit: query.limit ?? 20,
});
}
}Example 2b: Paginated List (Manual)
@Controller('users')
export class UsersController {
@Get()
@ApiOkContract(UserDto, true)
async listUsers(
@Query() query: { page?: number; limit?: number },
@Req() req: any,
) {
const { items, total } = await this.usersService.findAll({
page: query.page ?? 1,
limit: query.limit ?? 20,
});
req.paginationMeta = buildPaginationMeta({
page: query.page ?? 1,
limit: query.limit ?? 20,
total,
});
return items;
}
}Response:
{
"success": true,
"data": [
{ "id": "1", "name": "User 1" },
{ "id": "2", "name": "User 2" }
],
"meta": {
"timestamp": "2026-01-12T13:00:00.000Z",
"requestId": "req_abc123",
"pagination": {
"page": 1,
"limit": 20,
"total": 145,
"totalPages": 8
}
}
}Example 3: Using RequestContext
Access requestId and userId from services without req:
import { RequestContext } from '@ciphercross/nestjs-contracts';
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
async findAll() {
const requestId = RequestContext.getRequestId();
const userId = RequestContext.getUserId();
this.logger.log(`[${requestId}] Finding users for ${userId}`);
return this.repository.findAll();
}
}Example 4: Custom Request ID
If your gateway/ingress sets x-request-id header, it will be used:
GET /api/users HTTP/1.1
x-request-id: gateway-request-12345The same ID will be:
- Stored in
req.requestId - Included in response
meta.requestId - Returned in response header
x-request-id - Available via
RequestContext.getRequestId()(if enabled)
Example 5: Skip Wrapping
For file downloads, streaming, redirects:
import { SkipContract } from '@ciphercross/nestjs-contracts';
@Controller('files')
export class FilesController {
@SkipContract()
@Get('download/:id')
downloadFile(@Param('id') id: string, @Res() res: Response) {
return res.sendFile(`/path/to/file-${id}.pdf`);
}
}Type Safety
All types are fully typed and exported:
import type {
SuccessResponse,
ErrorResponse,
ResponseMeta,
PaginationMeta,
ApiError,
} from '@ciphercross/nestjs-contracts';
// Type inference works automatically
const result: SuccessResponse<User> = await fetchUser();Best Practices
- Use
ContractsModule.forRoot()for easy setup - Return
{ items, total, page?, limit? }(typePaginatedResult<T>) for automatic pagination (no@Req()needed)- Legacy/Escape hatch: Use
req.paginationMetaif you need more control
- Legacy/Escape hatch: Use
- Use
@ApiOkPaginatedContract()for paginated endpoints in Swagger - Return only data from controllers (interceptor wraps it)
- Use
@SkipContract()for file downloads, streaming, redirects - Use
RequestContextfor logging in services (no need to passreq) - Use unwrap helpers (
unwrapOrThrow,getErrors) in frontend for cleaner code - Import types in frontend for type safety
Troubleshooting
Request ID is "unknown"
Problem: Request ID shows as "unknown" in responses.
Solution: Ensure requestIdMiddleware is added before interceptors:
app.use(requestIdMiddleware); // Must be first
app.useGlobalInterceptors(new ResponseWrapInterceptor());Pagination Meta Not Included
Problem: meta.pagination is missing in response.
Solution A (Automatic - Recommended): Return { items, total, page?, limit? } (type PaginatedResult<T>) from controller:
return {
items: [...],
total: 145,
page: 1, // Optional: will use req.query.page if not provided
limit: 20, // Optional: will use req.query.limit if not provided
};Priority for page/limit:
result.page/result.limit(if service returned)req.query.page/req.query.limit(if present)config.defaultPagination.limit+page=1(default)
Solution B (Manual - Legacy): Set req.paginationMeta in controller:
req.paginationMeta = buildPaginationMeta({ page, limit, total });RequestContext Returns undefined
Problem: RequestContext.getRequestId() or RequestContext.getUserId() returns undefined.
Solution: Enable RequestContext in ContractsModule.forRoot():
ContractsModule.forRoot({
enableRequestContext: true,
extractUserId: (req) => req.user?.id, // Optional: if userId not in req.userId
})Swagger Schema Not Showing
Problem: Swagger doesn't show correct response schema.
Solution: Use @ApiOkContract decorator:
@ApiOkContract(UserDto)
@Get(':id')
async findOne() { ... }License
MIT
