├── .lintstagedrc.json ├── eslint.config.js ├── prettier.config.js ├── lib ├── types │ ├── config-option.type.ts │ ├── native-response-value.type.ts │ ├── handle-result.type.ts │ └── response-metadata.type.ts ├── constants │ ├── provider-key.constant.ts │ ├── native-class-response-names.constant.ts │ ├── nullable-grpc-class-response-names.constant.ts │ └── metadata.constant.ts ├── exceptions │ └── response-validate.exception.ts ├── decorators │ ├── use-response-interceptor.decorator.ts │ ├── response-models.decorator.ts │ └── response-model.decorator.ts ├── responses │ └── native-value.response.ts ├── index.ts ├── modules │ └── response.module.ts └── interceptors │ └── response.interceptor.ts ├── .npmrc ├── tsconfig.build.json ├── .husky └── pre-commit ├── renovate.json ├── .auto-changelog ├── cspell.json ├── sample ├── responses │ ├── user.response.ts │ ├── pagination.response.ts │ └── user-pagination.response.ts ├── main.ts ├── app.module.ts └── app.controller.ts ├── nest-cli.json ├── .gitignore ├── .github └── workflows │ └── publish.yml ├── tsconfig.json ├── package.json └── README.md /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "lib/**/*.ts": ["prettier --write"] 3 | } 4 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@hodfords/nestjs-eslint-config'); 2 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@hodfords/nestjs-prettier-config'); 2 | -------------------------------------------------------------------------------- /lib/types/config-option.type.ts: -------------------------------------------------------------------------------- 1 | export type ConfigOption = { 2 | excludedKeys?: string[]; 3 | }; 4 | -------------------------------------------------------------------------------- /lib/types/native-response-value.type.ts: -------------------------------------------------------------------------------- 1 | export type NativeResponseValueType = string | number | boolean; 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} 2 | registry=https://registry.npmjs.org/ 3 | always-auth=true -------------------------------------------------------------------------------- /lib/constants/provider-key.constant.ts: -------------------------------------------------------------------------------- 1 | export const NESTJS_RESPONSE_CONFIG_OPTIONS = 'NESTJS_RESPONSE_CONFIG_OPTIONS'; 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /lib/constants/native-class-response-names.constant.ts: -------------------------------------------------------------------------------- 1 | export const NativeClassResponseNamesConstant = ['Boolean', 'String', 'Number']; 2 | -------------------------------------------------------------------------------- /lib/constants/nullable-grpc-class-response-names.constant.ts: -------------------------------------------------------------------------------- 1 | export const NullableGrpcClassResponseNamePrefix = 'NullableGrpcClassResponseOf'; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | changedFiles="$(git diff --name-only --cached)" 2 | npm run cspell --no-must-find-files ${changedFiles} 3 | npm run lint-staged 4 | -------------------------------------------------------------------------------- /lib/constants/metadata.constant.ts: -------------------------------------------------------------------------------- 1 | export const RESPONSE_METADATA_KEY = 'response:class'; 2 | export const RESPONSE_METADATA_KEYS = 'response:classes'; 3 | -------------------------------------------------------------------------------- /lib/types/handle-result.type.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'class-validator'; 2 | 3 | export type HandleResult = { 4 | error: ValidationError; 5 | data: object | object[]; 6 | }; 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "prConcurrentLimit": 5, 5 | "assignees": ["hodfords_dung_senior_dev"], 6 | "labels": ["renovate"] 7 | } 8 | -------------------------------------------------------------------------------- /lib/exceptions/response-validate.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export class ResponseValidateException extends HttpException { 4 | constructor(public errors) { 5 | super({}, HttpStatus.INTERNAL_SERVER_ERROR); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.auto-changelog: -------------------------------------------------------------------------------- 1 | { 2 | "output": "CHANGELOG.md", 3 | "template": "keepachangelog", 4 | "unreleased": true, 5 | "ignoreCommitPattern": "^(?!(feat|fix|\\[feat\\]|\\[fix\\]))(.*)$", 6 | "commitLimit": false, 7 | "commitUrl": "https://github.com/hodfords-solutions/nestjs-storage/commit/{id}" 8 | } -------------------------------------------------------------------------------- /lib/decorators/use-response-interceptor.decorator.ts: -------------------------------------------------------------------------------- 1 | import { UseInterceptors } from '@nestjs/common'; 2 | import { ResponseInterceptor } from '../interceptors/response.interceptor'; 3 | 4 | export function UseResponseInterceptor(): MethodDecorator & ClassDecorator { 5 | return UseInterceptors(ResponseInterceptor); 6 | } 7 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en", 4 | "words": [ 5 | "nestjs", 6 | "hodfords", 7 | "postbuild", 8 | "metadatas", 9 | "npmjs" 10 | ], 11 | "flagWords": ["hte"], 12 | "ignorePaths": ["node_modules", "*.spec.ts", "*.e2e-spec.ts", "cspell.json", "dist"] 13 | } 14 | -------------------------------------------------------------------------------- /sample/responses/user.response.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UserResponse { 5 | @ApiProperty() 6 | @IsString() 7 | name: string; 8 | 9 | @ApiProperty() 10 | @IsString() 11 | @IsOptional() 12 | secretKey?: string; 13 | } 14 | -------------------------------------------------------------------------------- /lib/types/response-metadata.type.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-function-type */ 2 | import { ClassConstructor } from 'class-transformer'; 3 | 4 | export type ResponseMetadata = { 5 | responseClass: ClassConstructor; 6 | isArray: boolean; 7 | isAllowEmpty: boolean; 8 | }; 9 | 10 | export type ResponseClass = Function | [Function]; 11 | -------------------------------------------------------------------------------- /sample/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { AppModule } from './app.module'; 4 | 5 | async function bootstrap(): Promise { 6 | const app = await NestFactory.create(AppModule); 7 | await app.listen(2008); 8 | } 9 | bootstrap().then(); 10 | -------------------------------------------------------------------------------- /lib/responses/native-value.response.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class NativeValueResponse { 4 | @IsOptional() 5 | @IsString() 6 | string?: string; 7 | 8 | @IsOptional() 9 | @IsNumber() 10 | number?: number; 11 | 12 | @IsOptional() 13 | @IsBoolean() 14 | boolean?: boolean; 15 | } 16 | -------------------------------------------------------------------------------- /sample/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { ResponseModule } from '../lib/modules/response.module'; 4 | 5 | const nestjsResponseConfig = ResponseModule.forRoot({ 6 | excludedKeys: ['secret'] 7 | }); 8 | 9 | @Module({ 10 | imports: [nestjsResponseConfig], 11 | providers: [], 12 | controllers: [AppController] 13 | }) 14 | export class AppModule {} 15 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "sample", 4 | "projects": { 5 | "nestjs-response": { 6 | "type": "library", 7 | "root": "lib", 8 | "entryFile": "index", 9 | "sourceRoot": "lib" 10 | } 11 | }, 12 | "compilerOptions": { 13 | "webpack": false, 14 | "assets": [ 15 | { 16 | "include": "../lib/public/**", 17 | "watchAssets": true 18 | } 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sample/responses/pagination.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export abstract class PaginationResponse { 5 | abstract items: object[]; 6 | 7 | @ApiProperty() 8 | @IsNumber() 9 | total: number; 10 | 11 | @ApiProperty() 12 | @IsNumber() 13 | lastPage: number; 14 | 15 | @ApiProperty() 16 | @IsNumber() 17 | perPage: number; 18 | 19 | @ApiProperty() 20 | @IsNumber() 21 | currentPage: number; 22 | } 23 | -------------------------------------------------------------------------------- /sample/responses/user-pagination.response.ts: -------------------------------------------------------------------------------- 1 | import { IsArray, ValidateNested } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { PaginationResponse } from './pagination.response'; 4 | import { UserResponse } from './user.response'; 5 | import { Type } from 'class-transformer'; 6 | 7 | export class UserPaginationResponse extends PaginationResponse { 8 | @ApiProperty() 9 | @IsArray() 10 | @ValidateNested() 11 | @Type(() => UserResponse) 12 | items: UserResponse[]; 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './constants/metadata.constant'; 2 | export * from './constants/nullable-grpc-class-response-names.constant'; 3 | export * from './decorators/response-model.decorator'; 4 | export * from './decorators/use-response-interceptor.decorator'; 5 | export * from './exceptions/response-validate.exception'; 6 | export * from './interceptors/response.interceptor'; 7 | export * from './types/response-metadata.type'; 8 | export * from './decorators/response-models.decorator'; 9 | export * from './types/handle-result.type'; 10 | export * from './modules/response.module'; 11 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | lint: 8 | uses: hodfords-solutions/actions/.github/workflows/lint.yaml@main 9 | build: 10 | uses: hodfords-solutions/actions/.github/workflows/publish.yaml@main 11 | with: 12 | build_path: dist/lib 13 | secrets: 14 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 15 | update-docs: 16 | uses: hodfords-solutions/actions/.github/workflows/update-doc.yaml@main 17 | needs: build 18 | secrets: 19 | DOC_SSH_PRIVATE_KEY: ${{ secrets.DOC_SSH_PRIVATE_KEY }} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "esModuleInterop": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false, 21 | "paths": { 22 | "@hodfords/nestjs-response": ["lib"] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/modules/response.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module } from '@nestjs/common'; 2 | import { APP_INTERCEPTOR } from '@nestjs/core'; 3 | import { ResponseInterceptor } from '../interceptors/response.interceptor'; 4 | import { NESTJS_RESPONSE_CONFIG_OPTIONS } from '../constants/provider-key.constant'; 5 | import { ConfigOption } from '../types/config-option.type'; 6 | 7 | @Module({}) 8 | export class ResponseModule { 9 | static forRoot(option?: ConfigOption): DynamicModule { 10 | return { 11 | module: ResponseModule, 12 | providers: [ 13 | { 14 | provide: NESTJS_RESPONSE_CONFIG_OPTIONS, 15 | useValue: option 16 | }, 17 | { 18 | provide: APP_INTERCEPTOR, 19 | useClass: ResponseInterceptor 20 | } 21 | ], 22 | exports: [] 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/decorators/response-models.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ResponseClass } from '../types/response-metadata.type'; 2 | import { applyDecorators, SetMetadata } from '@nestjs/common'; 3 | import { ApiExtraModels, ApiOkResponse, refs } from '@nestjs/swagger'; 4 | import { RESPONSE_METADATA_KEYS } from '../constants/metadata.constant'; 5 | 6 | export function ResponseModels(...responseClasses: ResponseClass[]): MethodDecorator { 7 | const metadatas = responseClasses.map((metadata) => { 8 | const isArray = Array.isArray(metadata); 9 | const responseClass = isArray ? metadata[0] : metadata; 10 | const isAllowEmpty = metadata === undefined || metadata === null; 11 | return { isArray, responseClass, isAllowEmpty }; 12 | }); 13 | const models = metadatas.filter((metadata) => !metadata.isAllowEmpty).map((metadata) => metadata.responseClass); 14 | return applyDecorators( 15 | ApiExtraModels(...models), 16 | ApiOkResponse({ 17 | schema: { anyOf: refs(...models) } 18 | }), 19 | SetMetadata(RESPONSE_METADATA_KEYS, metadatas) 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /lib/decorators/response-model.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, SetMetadata } from '@nestjs/common'; 2 | import { ApiResponse } from '@nestjs/swagger'; 3 | import { ClassConstructor } from 'class-transformer'; 4 | import { RESPONSE_METADATA_KEY } from '../constants/metadata.constant'; 5 | 6 | export function ResponseModel( 7 | responseClass: ClassConstructor, 8 | isArray?: boolean, 9 | isAllowEmpty?: boolean 10 | ): MethodDecorator; 11 | export function ResponseModel( 12 | responseClass: ClassConstructor, 13 | options?: { isArray?: boolean; isAllowEmpty?: boolean } 14 | ): MethodDecorator; 15 | export function ResponseModel( 16 | responseClass: ClassConstructor, 17 | isArrayOrOptions?: boolean | { isArray?: boolean; isAllowEmpty?: boolean }, 18 | isAllowEmpty?: boolean 19 | ): MethodDecorator { 20 | let isArray: boolean; 21 | let allowEmpty: boolean; 22 | 23 | if (typeof isArrayOrOptions === 'object') { 24 | isArray = isArrayOrOptions?.isArray ?? false; 25 | allowEmpty = isArrayOrOptions?.isAllowEmpty ?? false; 26 | } else { 27 | isArray = isArrayOrOptions ?? false; 28 | allowEmpty = isAllowEmpty ?? false; 29 | } 30 | 31 | return applyDecorators( 32 | ApiResponse({ type: responseClass, isArray }), 33 | SetMetadata(RESPONSE_METADATA_KEY, { 34 | responseClass, 35 | isArray, 36 | isAllowEmpty: allowEmpty 37 | }) 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hodfords/nestjs-response", 3 | "version": "11.0.9", 4 | "description": "Standardizes and validates API responses in NestJS for consistent and reliable communication", 5 | "author": "", 6 | "license": "UNLICENSED", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/hodfords-solutions/nestjs-response.git" 10 | }, 11 | "scripts": { 12 | "prebuild": "rimraf dist", 13 | "build": "nest build", 14 | "postbuild": "cp package.json dist/lib && cp README.md dist/lib && cp .npmrc dist/lib", 15 | "format": "prettier --write \"sample/**/*.ts\" \"test/**/*.ts\" \"lib/**/*.ts\"", 16 | "start": "nest start", 17 | "start:dev": "npm run prebuild && nest start --watch", 18 | "start:debug": "nest start --debug --watch", 19 | "start:prod": "node dist/main", 20 | "lint": "eslint \"{sample,apps,lib,test}/**/*.ts\" --fix", 21 | "test": "jest", 22 | "test:watch": "jest --watch", 23 | "test:cov": "jest --coverage", 24 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 25 | "test:e2e": "jest --config ./test/jest-e2e.json", 26 | "wz-command": "wz-command", 27 | "prepare": "is-ci || husky", 28 | "version": "auto-changelog && git add CHANGELOG.md", 29 | "release:patch": "git add CHANGELOG.md && npm version patch --tag-version-prefix='' -f -m 'chore: release to %s'", 30 | "release:push": "git push --no-verify && git push --tags --no-verify", 31 | "lint-staged": "lint-staged", 32 | "cspell": "cspell" 33 | }, 34 | "devDependencies": { 35 | "@nestjs/cli": "11.0.5", 36 | "@nestjs/common": "11.0.11", 37 | "@nestjs/core": "11.0.11", 38 | "@nestjs/platform-express": "11.0.11", 39 | "@nestjs/schematics": "11.0.2", 40 | "@nestjs/swagger": "11.0.6", 41 | "@nestjs/testing": "11.0.11", 42 | "@types/express": "5.0.0", 43 | "@types/jest": "29.5.14", 44 | "@types/node": "22.13.10", 45 | "@types/supertest": "6.0.2", 46 | "auto-changelog": "2.5.0", 47 | "class-transformer": "0.5.1", 48 | "class-validator": "0.14.1", 49 | "cspell": "8.17.5", 50 | "eslint": "9.22.0", 51 | "husky": "9.1.7", 52 | "is-ci": "4.1.0", 53 | "jest": "29.7.0", 54 | "lint-staged": "15.5.0", 55 | "prettier": "3.5.3", 56 | "reflect-metadata": "0.2.2", 57 | "rimraf": "6.0.1", 58 | "rxjs": "7.8.2", 59 | "source-map-support": "0.5.21", 60 | "supertest": "7.0.0", 61 | "ts-jest": "29.2.6", 62 | "ts-loader": "9.5.2", 63 | "ts-node": "10.9.2", 64 | "tsconfig-paths": "4.2.0", 65 | "typescript": "5.8.2", 66 | "@hodfords/nestjs-eslint-config": "11.0.1", 67 | "@hodfords/nestjs-prettier-config": "11.0.1" 68 | }, 69 | "jest": { 70 | "moduleFileExtensions": [ 71 | "js", 72 | "json", 73 | "ts" 74 | ], 75 | "rootDir": ".", 76 | "testRegex": ".*\\.spec\\.ts$", 77 | "transform": { 78 | "^.+\\.(t|j)s$": "ts-jest" 79 | }, 80 | "collectCoverageFrom": [ 81 | "**/*.(t|j)s" 82 | ], 83 | "coverageDirectory": "./coverage", 84 | "testEnvironment": "node", 85 | "roots": [ 86 | "/sample/", 87 | "/lib/" 88 | ], 89 | "moduleNameMapper": { 90 | "^@hodfords/nestjs-response(|/.*)$": "/lib/nestjs-response/sample/$1" 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /sample/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, HttpCode, HttpStatus, Param } from '@nestjs/common'; 2 | import { UseResponseInterceptor, ResponseModel } from 'lib'; 3 | import { UserResponse } from './responses/user.response'; 4 | import { UserPaginationResponse } from './responses/user-pagination.response'; 5 | import { ResponseModels } from '../lib/decorators/response-models.decorator'; 6 | import { PaginationResponse } from './responses/pagination.response'; 7 | 8 | @Controller() 9 | @UseResponseInterceptor() 10 | export class AppController { 11 | @Get() 12 | @ResponseModel(UserResponse) 13 | @HttpCode(HttpStatus.OK) 14 | getSingle(): { name: string } { 15 | return { name: 'test' }; 16 | } 17 | 18 | @Get('exclude') 19 | @ResponseModel(UserResponse, false, true) 20 | @HttpCode(HttpStatus.OK) 21 | getExclude(): UserResponse { 22 | return { name: 'test', secretKey: 'secret' }; 23 | } 24 | 25 | @Get('undefined') 26 | @ResponseModel(UserResponse, false, true) 27 | @HttpCode(HttpStatus.OK) 28 | getUndefined(): undefined { 29 | return undefined; 30 | } 31 | 32 | @Get('multiple') 33 | @ResponseModel(UserResponse, true) 34 | @HttpCode(HttpStatus.OK) 35 | getMultiple(): UserResponse[] { 36 | return [{ name: 'test' }, { name: 'test2' }]; 37 | } 38 | 39 | @Get('pagination') 40 | @ResponseModel(UserPaginationResponse, false) 41 | @HttpCode(HttpStatus.OK) 42 | getPagination(): PaginationResponse { 43 | return { 44 | items: [{ name: 'test' }, { name: 'test2' }], 45 | total: 10, 46 | lastPage: 1, 47 | perPage: 1, 48 | currentPage: 1 49 | }; 50 | } 51 | 52 | @Get('boolean') 53 | @ResponseModel(Boolean, false) 54 | getBoolean(): boolean { 55 | return false; 56 | } 57 | 58 | @Get('list-boolean') 59 | @ResponseModel(Boolean, true) 60 | getListBooleans(): boolean[] { 61 | return [false, true, false, true, true]; 62 | } 63 | 64 | @Get('string') 65 | @ResponseModel(String, false) 66 | getString(): string { 67 | return 'foo'; 68 | } 69 | 70 | @Get('list-string') 71 | @ResponseModel(String, true) 72 | getListString(): string[] { 73 | return ['foo', 'bar']; 74 | } 75 | 76 | @Get('number') 77 | @ResponseModel(Number, false) 78 | getNumber(): number { 79 | return 123; 80 | } 81 | 82 | @Get('list-number') 83 | @ResponseModel(Number, true) 84 | getListNumber(): number[] { 85 | return [123, 456]; 86 | } 87 | 88 | @Get('list-models/:type') 89 | @ResponseModels(Number, [Number], UserPaginationResponse, [UserResponse], undefined, null) 90 | getModels( 91 | @Param('type') type: string 92 | ): number | number[] | UserPaginationResponse | UserResponse[] | undefined | null { 93 | if (type == 'undefined') { 94 | return undefined; 95 | } 96 | if (type == 'pagination') { 97 | return { 98 | items: [{ name: 'test' }, { name: 'test 2' }], 99 | total: 10, 100 | lastPage: 1, 101 | perPage: 1, 102 | currentPage: 1 103 | }; 104 | } 105 | if (type == 'multiple') { 106 | return [{ name: 'test' }, { name: 'test2' }]; 107 | } 108 | if (type == 'list-number') { 109 | return [123, 456]; 110 | } 111 | if (type == 'number') { 112 | return 456; 113 | } 114 | return null; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 |

6 | Nestjs-Response is a simple yet powerful library for managing API responses in a NestJS application. It provides decorators to handle response models, allowing easy integration with Swagger for API documentation and validation. 7 |

8 | 9 | ## Installation 🤖 10 | 11 | To begin using it, we first install the required dependencies. 12 | 13 | ``` 14 | npm install @hodfords/nestjs-response 15 | ``` 16 | 17 | ## Interceptor Setup 🚀 18 | 19 | - `Global Interceptor (Recommended):` 20 | 21 | Global interceptors are applied across the entire application. To set up a global interceptor, you can register it in the providers array in your module. 22 | 23 | ```typescript 24 | import { APP_INTERCEPTOR } from '@nestjs/core'; 25 | import { ResponseInterceptor } from '@hodfords/nestjs-response'; 26 | 27 | @Module({ 28 | providers: [ 29 | { 30 | provide: APP_INTERCEPTOR, 31 | useClass: ResponseInterceptor 32 | } 33 | ] 34 | }) 35 | export class AppModule {} 36 | ``` 37 | 38 | - `Interceptor with Decorator:` 39 | 40 | For microservices or specific scenarios, use the @UseInterceptors decorator to apply interceptors at the controller or method level. However, it's generally recommended to use global interceptors. 41 | 42 | ```typescript 43 | import { Controller } from '@nestjs/common'; 44 | import { UseResponseInterceptor } from '@hodfords/nestjs-response'; 45 | 46 | @Controller() 47 | @UseResponseInterceptor() 48 | export class AppController {} 49 | ``` 50 | 51 | ## Usage 🚀 52 | 53 | `@ResponseModel()` 54 | 55 | Use the @ResponseModel decorator when an API return single response type. 56 | 57 | Parameter: 58 | 59 | - `responseClass`: The class that defines the response model. 60 | - `isArray` (optional): Set to `true` if the response is an array of `responseClass`. Defaults to `false`. 61 | - `isAllowEmpty` (optional): Set to true if the response can be empty. Defaults to `false`. 62 | 63 | Example of usage: 64 | 65 | ```typescript 66 | import { ResponseModel } from '@hodfords/nestjs-response'; 67 | import { Get } from '@nestjs/common'; 68 | import { IsNotEmpty, IsString } from 'class-validator'; 69 | 70 | class UserResponse { 71 | @IsNotEmpty() 72 | @IsString() 73 | name: string; 74 | } 75 | 76 | export class UserController { 77 | @Get() 78 | @ResponseModel(UserResponse, true) 79 | getAllUser() { 80 | return [{ name: 'John' }]; 81 | } 82 | } 83 | ``` 84 | 85 | `@ResponseModels()` 86 | 87 | Use the @ResponseModels decorator when an API might return multiple response types. 88 | 89 | Parameter: 90 | 91 | - `...responseClasses`: A list of response classes or arrays of response classes. 92 | 93 | Example of usage: 94 | 95 | ```typescript 96 | import { ResponseModels } from '@hodfords/nestjs-response'; 97 | import { Controller, Get, Param } from '@nestjs/common'; 98 | import { UserResponse } from './responses/user.response'; 99 | import { UserPaginationResponse } from './responses/user-pagination.response'; 100 | 101 | @Controller() 102 | export class AppController { 103 | @Get('list-models/:type') 104 | @ResponseModels(Number, [Number], UserPaginationResponse, [UserResponse], undefined, null) 105 | getModels(@Param('type') type: string) { 106 | if (type == 'undefined') { 107 | return undefined; 108 | } 109 | if (type == 'pagination') { 110 | return { 111 | items: [{ name: 'John' }, { name: 'Daniel' }], 112 | total: 2, 113 | lastPage: 1, 114 | perPage: 10, 115 | currentPage: 1 116 | }; 117 | } 118 | if (type == 'multiple') { 119 | return [{ name: 'John' }, { name: 'Daniel' }]; 120 | } 121 | if (type == 'list-number') { 122 | return [123, 456]; 123 | } 124 | if (type == 'number') { 125 | return 456; 126 | } 127 | return null; 128 | } 129 | } 130 | 131 | ``` 132 | 133 | ### Exception Handling 134 | 135 | When the response data does not match the expected model, a validation exception will be raised. This ensures that the API returns data conforming to the defined structure. 136 | 137 | Example Case: If a property is expected to be a string, but a number is returned, a validation error will occur. 138 | 139 | ```typescript 140 | import { ResponseModel } from '@hodfords/nestjs-response'; 141 | import { Get } from '@nestjs/common'; 142 | import { IsString } from 'class-validator'; 143 | 144 | class UserResponse { 145 | @IsString() 146 | name: string; 147 | } 148 | 149 | export class UserController { 150 | @Get() 151 | @ResponseModel(UserResponse) 152 | getUser() { 153 | return { name: 123 }; // Error: name must be a number ... 154 | } 155 | } 156 | 157 | ``` 158 | 159 | ## License 160 | 161 | This project is licensed under the MIT License 162 | -------------------------------------------------------------------------------- /lib/interceptors/response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { isBoolean, validateSync } from 'class-validator'; 4 | import { NESTJS_RESPONSE_CONFIG_OPTIONS } from 'lib/constants/provider-key.constant'; 5 | import { ConfigOption } from 'lib/types/config-option.type'; 6 | import { Observable, map } from 'rxjs'; 7 | import { RESPONSE_METADATA_KEY, RESPONSE_METADATA_KEYS } from '../constants/metadata.constant'; 8 | import { ResponseValidateException } from '../exceptions/response-validate.exception'; 9 | import { NativeValueResponse } from '../responses/native-value.response'; 10 | import { HandleResult } from '../types/handle-result.type'; 11 | import { ResponseMetadata } from '../types/response-metadata.type'; 12 | import { NativeClassResponseNamesConstant } from '../constants/native-class-response-names.constant'; 13 | import { ModuleRef } from '@nestjs/core'; 14 | import { NativeResponseValueType } from '../types/native-response-value.type'; 15 | import { NullableGrpcClassResponseNamePrefix } from 'lib/constants/nullable-grpc-class-response-names.constant'; 16 | 17 | let grpcMetadataClass = null; 18 | 19 | try { 20 | // Check project is using grpc 21 | // eslint-disable-next-line 22 | const grpc = require('@grpc/grpc-js'); 23 | grpcMetadataClass = grpc.Metadata; 24 | } catch (ex) { 25 | console.log(ex?.message); 26 | } 27 | 28 | @Injectable() 29 | export class ResponseInterceptor implements NestInterceptor { 30 | private readonly logger = new Logger(ResponseInterceptor.name); 31 | private configOption: ConfigOption; 32 | 33 | constructor(private moduleRef: ModuleRef) { 34 | this.configOption = this.moduleRef.get(NESTJS_RESPONSE_CONFIG_OPTIONS, { strict: false }); 35 | } 36 | 37 | intercept(context: ExecutionContext, next: CallHandler): Observable { 38 | return next.handle().pipe( 39 | map((data) => { 40 | const res = this.handleResponse(context, data); 41 | 42 | return this.excludeByKeys(context, res, this.configOption?.excludedKeys || []); 43 | }) 44 | ); 45 | } 46 | 47 | handleResponse(context: ExecutionContext, data: object): object { 48 | const responseMetadata: ResponseMetadata = Reflect.getMetadata(RESPONSE_METADATA_KEY, context.getHandler()); 49 | const responseMetadatas: ResponseMetadata[] = Reflect.getMetadata(RESPONSE_METADATA_KEYS, context.getHandler()); 50 | if (responseMetadata) { 51 | return this.handleMultiTypeResponse(context, data, [responseMetadata]); 52 | } 53 | if (responseMetadatas && responseMetadatas.length > 0) { 54 | return this.handleMultiTypeResponse(context, data, responseMetadatas); 55 | } 56 | return data; 57 | } 58 | 59 | private excludeByKeys(context: ExecutionContext, data: object, keys: string[]): object { 60 | /** 61 | * Check if endpoint has response metadata, if not, then return data 62 | */ 63 | if ( 64 | !Reflect.getMetadata(RESPONSE_METADATA_KEY, context.getHandler()) && 65 | !Reflect.getMetadata(RESPONSE_METADATA_KEYS, context.getHandler()) 66 | ) { 67 | return data; 68 | } 69 | 70 | for (const key of keys) { 71 | data = this.excludeByKey(data, key); 72 | } 73 | 74 | return data; 75 | } 76 | 77 | private excludeByKey(data: object | object[], key: string): object { 78 | for (const prop in data) { 79 | if (typeof data[prop] === 'object') { 80 | data[prop] = this.excludeByKey(data[prop], key); 81 | } else if (Array.isArray(data[prop])) { 82 | data[prop] = data[prop].map((item: object) => this.excludeByKey(item, key)); 83 | } else if (prop === key) { 84 | delete data[prop]; 85 | } 86 | } 87 | 88 | return data; 89 | } 90 | 91 | private handleOneTypeResponse( 92 | context: ExecutionContext, 93 | data: object | object[], 94 | responseMetadata: ResponseMetadata 95 | ): object { 96 | if (!isBoolean(data) && (data === null || data === undefined)) { 97 | return this.handleEmptyResponse(responseMetadata, data); 98 | } 99 | if (responseMetadata.isArray) { 100 | return this.handleListResponse(context, responseMetadata, data as object[]); 101 | } 102 | 103 | if ( 104 | NativeClassResponseNamesConstant.includes(responseMetadata.responseClass.name) && 105 | grpcMetadataClass && 106 | context.switchToRpc().getContext() instanceof grpcMetadataClass 107 | ) { 108 | return { value: this.handleNativeValueResponse(responseMetadata, data), grpcNative: true }; 109 | } 110 | 111 | return this.handleSingleResponse(context, responseMetadata, data); 112 | } 113 | 114 | private handleMultiTypeResponse( 115 | context: ExecutionContext, 116 | data: object, 117 | responseMetadatas: ResponseMetadata[] 118 | ): object { 119 | const results: HandleResult[] = []; 120 | const newMetadatas = this.filterResponseMetadatas(responseMetadatas, data); 121 | for (const metadata of newMetadatas) { 122 | try { 123 | let result = this.handleOneTypeResponse(context, data, metadata); 124 | if ( 125 | metadata.isAllowEmpty && 126 | grpcMetadataClass && 127 | context.switchToRpc().getContext() instanceof grpcMetadataClass 128 | ) { 129 | result = { value: result, grpcNullable: true }; 130 | } 131 | 132 | results.push({ data: result, error: null }); 133 | } catch (error) { 134 | const newError = error.errors ? error.errors : error; 135 | results.push({ data: null, error: newError }); 136 | } 137 | } 138 | const resultValid = results.find((result) => result.error === null); 139 | if (resultValid) { 140 | return resultValid.data; 141 | } 142 | const errors = results.map((result) => result.error); 143 | this.logger.error(errors); 144 | throw new ResponseValidateException(errors); 145 | } 146 | 147 | private handleSingleResponse(context: ExecutionContext, responseMetadata: ResponseMetadata, data: object): object { 148 | if (NativeClassResponseNamesConstant.includes(responseMetadata.responseClass.name)) { 149 | return this.handleNativeValueResponse(responseMetadata, data); 150 | } 151 | let options = {}; 152 | if (context.switchToRpc().getContext() instanceof grpcMetadataClass) { 153 | options = { groups: ['__sendData', '__grpc'] }; 154 | } 155 | 156 | const newData = plainToInstance(responseMetadata.responseClass, data, options); 157 | const errors = validateSync(newData, { 158 | whitelist: true, 159 | stopAtFirstError: true 160 | }); 161 | if (errors.length) { 162 | throw new ResponseValidateException(errors); 163 | } 164 | return newData; 165 | } 166 | 167 | private handleListResponse( 168 | context: ExecutionContext, 169 | responseMetadata: ResponseMetadata, 170 | data: object[] 171 | ): object[] | { items: object[]; grpcArray: boolean } { 172 | const newData: object[] = []; 173 | for (const item of data) { 174 | const newItem = this.handleSingleResponse(context, responseMetadata, item); 175 | newData.push(newItem); 176 | } 177 | if (grpcMetadataClass && context.switchToRpc().getContext() instanceof grpcMetadataClass) { 178 | return { items: newData, grpcArray: true }; 179 | } 180 | return newData; 181 | } 182 | 183 | private handleEmptyResponse(responseMetadata: ResponseMetadata, data: object): object { 184 | if (responseMetadata.isAllowEmpty) { 185 | return data; 186 | } else { 187 | throw new ResponseValidateException([ 188 | { 189 | target: data, 190 | children: [], 191 | constraints: { 192 | nullValue: 'an null value was passed to the validate function' 193 | } 194 | } 195 | ]); 196 | } 197 | } 198 | 199 | private handleNativeValueResponse(responseMetadata: ResponseMetadata, data: object): any { 200 | const responseMap = this.getResponseMap(responseMetadata, data); 201 | const newData = plainToInstance(NativeValueResponse, responseMap); 202 | const errors = validateSync(newData, { 203 | whitelist: true, 204 | stopAtFirstError: true 205 | }); 206 | if (errors.length) { 207 | throw new ResponseValidateException(errors); 208 | } 209 | return this.getNativeResponseValue(responseMetadata, newData); 210 | } 211 | 212 | private getNativeResponseValue( 213 | responseMetadata: ResponseMetadata, 214 | data: NativeValueResponse 215 | ): NativeResponseValueType { 216 | switch (responseMetadata.responseClass.name) { 217 | case 'Boolean': 218 | return data['boolean']; 219 | case 'String': 220 | return data['string']; 221 | case 'Number': 222 | return data['number']; 223 | default: 224 | return null; 225 | } 226 | } 227 | 228 | private getResponseMap(responseMetadata: ResponseMetadata, data: object): Record { 229 | switch (responseMetadata.responseClass.name) { 230 | case 'Boolean': 231 | return { ['boolean']: data }; 232 | case 'String': 233 | return { ['string']: data }; 234 | case 'Number': 235 | return { ['number']: data }; 236 | default: 237 | return {}; 238 | } 239 | } 240 | 241 | private filterResponseMetadatas(responseMetadatas: ResponseMetadata[], data: object): ResponseMetadata[] { 242 | if (Array.isArray(data)) { 243 | return this.getArrayTypeMetadata(responseMetadatas, data); 244 | } 245 | if (!isBoolean(data) && (data === null || data === undefined)) { 246 | return this.getEmptyTypeMetadata(responseMetadatas, data); 247 | } 248 | return this.getObjectTypeMetadata(responseMetadatas, data); 249 | } 250 | 251 | private getArrayTypeMetadata(responseMetadatas: ResponseMetadata[], data: object): ResponseMetadata[] { 252 | const arrayTypes = responseMetadatas.filter((metadata) => metadata.isArray); 253 | if (arrayTypes.length == 0) { 254 | throw new ResponseValidateException([ 255 | { 256 | target: data, 257 | children: [], 258 | constraints: { 259 | arrayValue: 'an array value was passed to the validate function' 260 | } 261 | } 262 | ]); 263 | } 264 | return arrayTypes; 265 | } 266 | 267 | private getEmptyTypeMetadata(responseMetadatas: ResponseMetadata[], data: object): ResponseMetadata[] { 268 | const emptyType = responseMetadatas.find((metadata) => metadata.isAllowEmpty); 269 | if (emptyType) { 270 | return [emptyType]; 271 | } 272 | throw new ResponseValidateException([ 273 | { 274 | target: data, 275 | children: [], 276 | constraints: { 277 | arrayValue: 'an empty value was passed to the validate function' 278 | } 279 | } 280 | ]); 281 | } 282 | 283 | private getObjectTypeMetadata(responseMetadatas: ResponseMetadata[], data: object): ResponseMetadata[] { 284 | const objectTypes = responseMetadatas.filter((metadata) => !metadata.isArray); 285 | if (objectTypes.length == 0) { 286 | throw new ResponseValidateException([ 287 | { 288 | target: data, 289 | children: [], 290 | constraints: { 291 | arrayValue: 'an object value was passed to the validate function' 292 | } 293 | } 294 | ]); 295 | } 296 | return objectTypes; 297 | } 298 | } 299 | --------------------------------------------------------------------------------