mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-07-09 22:57:26 +00:00
feat:mvp1 initial commit
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@ -0,0 +1,17 @@
|
||||
NODE_ENV=dev
|
||||
PORT=5000
|
||||
DB_HOST=
|
||||
DB_PASS=
|
||||
DB_USER=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=
|
||||
MIGRATIONS_RUN=true
|
||||
SWAGGER_API_DOCS_PATH="/api-docs"
|
||||
|
||||
|
||||
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_USER=aahalhmad@gmail.com
|
||||
MAIL_PASSWORD=
|
||||
MAIL_PORT=587
|
||||
MAIL_FROM=UBA
|
47
.eslintrc.js
Normal file
47
.eslintrc.js
Normal file
@ -0,0 +1,47 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:security/recommended'],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js', 'node_modules', 'dist', 'coverage', 'jest*\\.ts', 'typeorm*\\.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-inferrable-types': 'off',
|
||||
'func-names': ['error', 'as-needed'],
|
||||
'no-underscore-dangle': ['error'],
|
||||
'require-await': ['error'],
|
||||
'no-console': ['error'],
|
||||
'no-multi-assign': ['error'],
|
||||
'no-magic-numbers': ['error', { ignoreArrayIndexes: true }],
|
||||
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1, maxBOF: 0 }],
|
||||
'max-len': [
|
||||
'error',
|
||||
{ code: 120, tabWidth: 2, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true },
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*spec.ts'],
|
||||
rules: {
|
||||
'no-magic-numbers': ['off'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
85
.github/workflows/ci-pipeline.yml
vendored
Normal file
85
.github/workflows/ci-pipeline.yml
vendored
Normal file
@ -0,0 +1,85 @@
|
||||
name: CI Zod
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
|
||||
env:
|
||||
NODE_ENV: development
|
||||
TAG: dev
|
||||
|
||||
jobs:
|
||||
install_dependencies:
|
||||
name: Install Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22.4.1
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node_modules-
|
||||
|
||||
run_tests:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: install_dependencies
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22.4.1
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Tests with Coverage
|
||||
run: npm run test:cov
|
||||
|
||||
build_app:
|
||||
name: Build Application
|
||||
runs-on: ubuntu-latest
|
||||
needs: run_tests
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22.4.1
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build Application
|
||||
run: npm run build
|
||||
|
||||
- name: Upload Build Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 120
|
||||
}
|
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
}
|
33
Dockerfile.local
Normal file
33
Dockerfile.local
Normal file
@ -0,0 +1,33 @@
|
||||
# Base image
|
||||
FROM node:22.4.1-alpine
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add ca-certificates curl && \
|
||||
update-ca-certificates
|
||||
|
||||
#saudi timezone
|
||||
ENV TZ=Asia/Riyadh
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir /app && chown node:node /app
|
||||
WORKDIR /app
|
||||
|
||||
# finally switch user to node
|
||||
USER node
|
||||
|
||||
|
||||
# A wildcard is used to ensure both package.json, package-lock.json and package-lock.json are copied
|
||||
COPY --chown=node:node package*.json ./
|
||||
|
||||
|
||||
|
||||
# Install app dependencies
|
||||
RUN npm install --frozen-lockfile
|
||||
# Bundle app source
|
||||
COPY --chown=node:node . .
|
||||
|
||||
# Creates a "dist" folder with the production build
|
||||
RUN npm run build
|
||||
|
||||
# Start the server using the production build
|
||||
CMD [ "node", "dist/main" ]
|
99
README.md
Normal file
99
README.md
Normal file
@ -0,0 +1,99 @@
|
||||
lign="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
35
jest.base.config.ts
Normal file
35
jest.base.config.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { Config as JestConfig } from 'jest';
|
||||
import { pathsToModuleNameMapper } from 'ts-jest';
|
||||
import { compilerOptions as tsConfigCompilerOptions } from './tsconfig.json';
|
||||
|
||||
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
|
||||
type JestProjectConfig = Exclude<ArrayElement<JestConfig['projects']>, string>;
|
||||
|
||||
const baseJestConfig: JestConfig = {
|
||||
// ...
|
||||
};
|
||||
const baseJestProjectConfig: JestProjectConfig = {
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
moduleNameMapper: pathsToModuleNameMapper(tsConfigCompilerOptions.paths, { prefix: '<rootDir>' }),
|
||||
globalSetup: '<rootDir>/test/global-setup.ts',
|
||||
};
|
||||
const projects: { name?: string; rootDir: string }[] = [{ rootDir: '.' }];
|
||||
|
||||
export function buildJestConfig(config?: {
|
||||
jestConfigs?: JestConfig;
|
||||
jestProjectConfigs?: JestProjectConfig;
|
||||
}): JestConfig {
|
||||
return {
|
||||
...baseJestConfig,
|
||||
...config?.jestConfigs,
|
||||
projects: projects.map(({ name, rootDir }) => ({
|
||||
...baseJestProjectConfig,
|
||||
...config?.jestProjectConfigs,
|
||||
...(name && { displayName: name }),
|
||||
rootDir,
|
||||
})),
|
||||
};
|
||||
}
|
16
jest.config.ts
Normal file
16
jest.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Config } from 'jest';
|
||||
import { buildJestConfig } from './jest.base.config';
|
||||
|
||||
const config: Config = buildJestConfig({
|
||||
jestProjectConfigs: {
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
coveragePathIgnorePatterns: ['__testing__', 'entities', '<rootDir>/src/db', 'index.ts'],
|
||||
},
|
||||
jestConfigs: {
|
||||
coverageDirectory: 'coverage',
|
||||
collectCoverageFrom: ['<rootDir>/src/**/*.(t|j)s'],
|
||||
coverageReporters: ['clover', 'json', 'lcov', 'text', 'text-summary', 'cobertura'],
|
||||
},
|
||||
});
|
||||
|
||||
export default config;
|
17
nest-cli.json
Normal file
17
nest-cli.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"watchAssets": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "config",
|
||||
"exclude": "**/*.md"
|
||||
},
|
||||
"i18n",
|
||||
"files"
|
||||
]
|
||||
}
|
||||
}
|
15812
package-lock.json
generated
Normal file
15812
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
97
package.json
Normal file
97
package.json
Normal file
@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "test",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"typeorm:cli": "./node_modules/.bin/ts-node -r tsconfig-paths/register ./node_modules/.bin/typeorm",
|
||||
"typeorm:cli-d": "ts-node -r tsconfig-paths/register ./node_modules/.bin/typeorm -d typeorm.cli.ts",
|
||||
"migration:generate": "npm run typeorm:cli-d migration:generate",
|
||||
"migration:create": "npm run typeorm:cli migration:create",
|
||||
"migration:up": "npm run typeorm:cli-d migration:run",
|
||||
"migration:down": "npm run typeorm:cli-d migration:revert"
|
||||
},
|
||||
"dependencies": {
|
||||
"@abdalhamid/hello": "^2.0.0",
|
||||
"@hamid/hello": "file:../libraries/test-package",
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/axios": "^3.1.2",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/event-emitter": "^2.1.1",
|
||||
"@nestjs/microservices": "^10.4.7",
|
||||
"@nestjs/platform-express": "^10.4.8",
|
||||
"@nestjs/swagger": "^8.0.5",
|
||||
"@nestjs/terminus": "^10.2.3",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"amqp-connection-manager": "^4.1.14",
|
||||
"amqplib": "^0.10.4",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"handlebars": "^4.7.8",
|
||||
"ioredis": "^5.4.1",
|
||||
"nestjs-i18n": "^10.4.9",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"oci-common": "^2.98.1",
|
||||
"oci-sdk": "^2.98.1",
|
||||
"pg": "^8.13.1",
|
||||
"pino-http": "^10.3.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@golevelup/ts-jest": "^0.6.0",
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
||||
"@typescript-eslint/parser": "^5.59.2",
|
||||
"eslint": "^8.39.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-security": "^1.7.1",
|
||||
"jest": "^29.5.0",
|
||||
"lint-staged": "^13.2.2",
|
||||
"prettier": "^2.8.8",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts}": [
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.{js,ts,json,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
47
src/app.module.ts
Normal file
47
src/app.module.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { MiddlewareConsumer, Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||
import { ClientProxyFactory, Transport } from '@nestjs/microservices';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
|
||||
import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options';
|
||||
import { buildI18nOptions } from './core/module-options/i18n-options';
|
||||
import { buildValidationPipe } from './core/pipes';
|
||||
import { migrations } from './db';
|
||||
import { HealthModule } from './health/health.module';
|
||||
@Module({
|
||||
controllers: [],
|
||||
imports: [
|
||||
ConfigModule.forRoot(buildConfigOptions()),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => buildTypeormOptions(config, migrations),
|
||||
}),
|
||||
LoggerModule.forRootAsync({
|
||||
useFactory: (config: ConfigService) => buildLoggerOptions(config),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
I18nModule.forRoot(buildI18nOptions()),
|
||||
HealthModule,
|
||||
],
|
||||
providers: [
|
||||
// Global Pipes
|
||||
{
|
||||
inject: [ConfigService],
|
||||
provide: APP_PIPE,
|
||||
useFactory: (config: ConfigService) => buildValidationPipe(config),
|
||||
},
|
||||
|
||||
// Global Filters (order matters)
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
{ provide: APP_FILTER, useValue: buildI18nValidationExceptionFilter() },
|
||||
],
|
||||
})
|
||||
export class AppModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(I18nMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
1
src/core/constants/headers.constant.ts
Normal file
1
src/core/constants/headers.constant.ts
Normal file
@ -0,0 +1 @@
|
||||
export const LANGUAGE_HEADER_NAME = 'Accept-Language';
|
1
src/core/constants/index.ts
Normal file
1
src/core/constants/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './headers.constant';
|
@ -0,0 +1,73 @@
|
||||
const apiExtraModelsMock = jest.fn();
|
||||
const apiResponseMock = jest.fn();
|
||||
const getSchemaPathMock = jest.fn();
|
||||
jest.mock('@nestjs/swagger', () => ({
|
||||
ApiExtraModels: apiExtraModelsMock,
|
||||
ApiResponse: apiResponseMock,
|
||||
getSchemaPath: getSchemaPathMock,
|
||||
}));
|
||||
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { ApiDataArrayResponse } from './data-array-response.decorator';
|
||||
|
||||
class MockModel {}
|
||||
|
||||
describe('ApiDataArrayResponse', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Model responses', () => {
|
||||
it.each([
|
||||
{
|
||||
passedStatus: undefined,
|
||||
expectedStatus: HttpStatus.OK,
|
||||
},
|
||||
{
|
||||
passedStatus: HttpStatus.CREATED,
|
||||
expectedStatus: HttpStatus.CREATED,
|
||||
},
|
||||
])('should return ApiDataArrayResponse decorator with proper settings', ({ passedStatus, expectedStatus }) => {
|
||||
const modelSchemaPathMock = 'model-path';
|
||||
getSchemaPathMock.mockReturnValueOnce(modelSchemaPathMock);
|
||||
|
||||
ApiDataArrayResponse(MockModel, passedStatus);
|
||||
|
||||
expect(apiExtraModelsMock).toHaveBeenCalledWith(MockModel);
|
||||
expect(getSchemaPathMock).toHaveBeenCalledWith(MockModel);
|
||||
expect(apiResponseMock).toHaveBeenCalledWith({
|
||||
status: expectedStatus,
|
||||
description: 'Successful list response',
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: modelSchemaPathMock },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Primitive responses', () => {
|
||||
it('should return ApiDataArrayResponse decorator with proper settings', () => {
|
||||
ApiDataArrayResponse('boolean');
|
||||
|
||||
expect(apiExtraModelsMock).not.toHaveBeenCalled();
|
||||
expect(getSchemaPathMock).not.toHaveBeenCalled();
|
||||
expect(apiResponseMock).toHaveBeenCalledWith({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Successful list response',
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
35
src/core/decorators/api/data-array-response.decorator.ts
Normal file
35
src/core/decorators/api/data-array-response.decorator.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { applyDecorators, HttpStatus, Type } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Define the response structure of the endpoint as a list of items response.
|
||||
* We can pass complex model type such as DTOs, or primitive names as strings (e.g. 'boolean').
|
||||
*/
|
||||
export const ApiDataArrayResponse = <TModel extends Type>(model: TModel | string, status = HttpStatus.OK) => {
|
||||
const decorators = [];
|
||||
const modelProperties: { $ref?: string; type?: string } = {};
|
||||
|
||||
if (typeof model !== 'string') {
|
||||
decorators.push(ApiExtraModels(model));
|
||||
modelProperties.$ref = getSchemaPath(model);
|
||||
} else {
|
||||
modelProperties.type = model;
|
||||
}
|
||||
|
||||
decorators.push(
|
||||
ApiResponse({
|
||||
status,
|
||||
description: 'Successful list response',
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: modelProperties,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
92
src/core/decorators/api/data-page-response.decorator.spec.ts
Normal file
92
src/core/decorators/api/data-page-response.decorator.spec.ts
Normal file
@ -0,0 +1,92 @@
|
||||
const apiExtraModelsMock = jest.fn();
|
||||
const apiResponseMock = jest.fn();
|
||||
const getSchemaPathMock = jest.fn();
|
||||
jest.mock('@nestjs/swagger', () => ({
|
||||
ApiExtraModels: apiExtraModelsMock,
|
||||
ApiResponse: apiResponseMock,
|
||||
getSchemaPath: getSchemaPathMock,
|
||||
ApiPropertyOptional: () => jest.fn(),
|
||||
ApiProperty: () => jest.fn(),
|
||||
}));
|
||||
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { DataPageResponseDto } from '~/core/dtos';
|
||||
import { ApiDataPageResponse } from './data-page-response.decorator';
|
||||
|
||||
class MockModel {}
|
||||
|
||||
describe('ApiDataPageResponse', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Model responses', () => {
|
||||
it.each([
|
||||
{
|
||||
passedStatus: undefined,
|
||||
expectedStatus: HttpStatus.OK,
|
||||
},
|
||||
{
|
||||
passedStatus: HttpStatus.CREATED,
|
||||
expectedStatus: HttpStatus.CREATED,
|
||||
},
|
||||
])('should return ApiDataPageResponse decorator with proper settings', ({ passedStatus, expectedStatus }) => {
|
||||
const modelSchemaPathMock = 'model-path';
|
||||
getSchemaPathMock.mockReturnValueOnce(modelSchemaPathMock);
|
||||
const dataPageSchemaPathMock = 'data-page-path';
|
||||
getSchemaPathMock.mockReturnValueOnce(dataPageSchemaPathMock);
|
||||
|
||||
ApiDataPageResponse(MockModel, passedStatus);
|
||||
|
||||
expect(apiExtraModelsMock).toHaveBeenCalledWith(DataPageResponseDto, MockModel);
|
||||
expect(getSchemaPathMock).toHaveBeenCalledWith(DataPageResponseDto);
|
||||
expect(getSchemaPathMock).toHaveBeenCalledWith(MockModel);
|
||||
expect(apiResponseMock).toHaveBeenCalledWith({
|
||||
status: expectedStatus,
|
||||
description: 'Successful paginated list response',
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: dataPageSchemaPathMock },
|
||||
{
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: modelSchemaPathMock },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Primitive responses', () => {
|
||||
it('should return ApiDataPageResponse decorator with proper settings', () => {
|
||||
const dataPageSchemaPathMock = 'data-page-path';
|
||||
getSchemaPathMock.mockReturnValueOnce(dataPageSchemaPathMock);
|
||||
|
||||
ApiDataPageResponse('boolean');
|
||||
|
||||
expect(apiExtraModelsMock).toHaveBeenCalledWith(DataPageResponseDto);
|
||||
expect(getSchemaPathMock).toHaveBeenCalledWith(DataPageResponseDto);
|
||||
expect(apiResponseMock).toHaveBeenCalledWith({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Successful paginated list response',
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: dataPageSchemaPathMock },
|
||||
{
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
42
src/core/decorators/api/data-page-response.decorator.ts
Normal file
42
src/core/decorators/api/data-page-response.decorator.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { applyDecorators, HttpStatus, Type } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
|
||||
import { DataPageResponseDto } from '~/core/dtos';
|
||||
|
||||
/**
|
||||
* Define the response structure of the endpoint as a page of items response.
|
||||
* We can pass complex model type such as DTOs, or primitive names as strings (e.g. 'boolean').
|
||||
*/
|
||||
export const ApiDataPageResponse = <TModel extends Type>(model: TModel | string, status = HttpStatus.OK) => {
|
||||
const decorators = [];
|
||||
const modelProperties: { $ref?: string; type?: string } = {};
|
||||
|
||||
if (typeof model !== 'string') {
|
||||
decorators.push(ApiExtraModels(DataPageResponseDto, model));
|
||||
modelProperties.$ref = getSchemaPath(model);
|
||||
} else {
|
||||
decorators.push(ApiExtraModels(DataPageResponseDto));
|
||||
modelProperties.type = model;
|
||||
}
|
||||
|
||||
decorators.push(
|
||||
ApiResponse({
|
||||
status,
|
||||
description: 'Successful paginated list response',
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: getSchemaPath(DataPageResponseDto) },
|
||||
{
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: modelProperties,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
67
src/core/decorators/api/data-response.decorator.spec.ts
Normal file
67
src/core/decorators/api/data-response.decorator.spec.ts
Normal file
@ -0,0 +1,67 @@
|
||||
const apiExtraModelsMock = jest.fn();
|
||||
const apiResponseMock = jest.fn();
|
||||
const getSchemaPathMock = jest.fn();
|
||||
jest.mock('@nestjs/swagger', () => ({
|
||||
ApiExtraModels: apiExtraModelsMock,
|
||||
ApiResponse: apiResponseMock,
|
||||
getSchemaPath: getSchemaPathMock,
|
||||
}));
|
||||
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { ApiDataResponse } from './data-response.decorator';
|
||||
|
||||
class MockModel {}
|
||||
|
||||
describe('ApiDataResponse', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Model responses', () => {
|
||||
it.each([
|
||||
{
|
||||
passedStatus: undefined,
|
||||
expectedStatus: HttpStatus.OK,
|
||||
},
|
||||
{
|
||||
passedStatus: HttpStatus.CREATED,
|
||||
expectedStatus: HttpStatus.CREATED,
|
||||
},
|
||||
])('should return ApiDataResponse decorator with proper settings', ({ passedStatus, expectedStatus }) => {
|
||||
const modelSchemaPathMock = 'model-path';
|
||||
getSchemaPathMock.mockReturnValueOnce(modelSchemaPathMock);
|
||||
|
||||
ApiDataResponse(MockModel, passedStatus);
|
||||
|
||||
expect(apiExtraModelsMock).toHaveBeenCalledWith(MockModel);
|
||||
expect(getSchemaPathMock).toHaveBeenCalledWith(MockModel);
|
||||
expect(apiResponseMock).toHaveBeenCalledWith({
|
||||
status: expectedStatus,
|
||||
description: 'Successful single item response',
|
||||
schema: {
|
||||
properties: {
|
||||
data: { $ref: modelSchemaPathMock },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Primitive responses', () => {
|
||||
it('should return ApiDataResponse decorator with proper settings', () => {
|
||||
ApiDataResponse('boolean');
|
||||
|
||||
expect(apiExtraModelsMock).not.toHaveBeenCalled();
|
||||
expect(getSchemaPathMock).not.toHaveBeenCalled();
|
||||
expect(apiResponseMock).toHaveBeenCalledWith({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Successful single item response',
|
||||
schema: {
|
||||
properties: {
|
||||
data: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
32
src/core/decorators/api/data-response.decorator.ts
Normal file
32
src/core/decorators/api/data-response.decorator.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { applyDecorators, HttpStatus, Type } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Define the response structure of the endpoint as a single item response.
|
||||
* We can pass complex model type such as DTOs, or primitive names as strings (e.g. 'boolean').
|
||||
*/
|
||||
export const ApiDataResponse = <TModel extends Type>(model: TModel | string, status = HttpStatus.OK) => {
|
||||
const decorators = [];
|
||||
const modelProperties: { $ref?: string; type?: string } = {};
|
||||
|
||||
if (typeof model !== 'string') {
|
||||
decorators.push(ApiExtraModels(model));
|
||||
modelProperties.$ref = getSchemaPath(model);
|
||||
} else {
|
||||
modelProperties.type = model;
|
||||
}
|
||||
|
||||
decorators.push(
|
||||
ApiResponse({
|
||||
status,
|
||||
description: 'Successful single item response',
|
||||
schema: {
|
||||
properties: {
|
||||
data: modelProperties,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
const apiForbiddenResponseMock = jest.fn();
|
||||
jest.mock('@nestjs/swagger', () => ({
|
||||
ApiForbiddenResponse: apiForbiddenResponseMock,
|
||||
ApiProperty: () => jest.fn(),
|
||||
}));
|
||||
|
||||
import { ApiForbiddenResponseBody } from './forbidden-response-body.decorator';
|
||||
|
||||
describe('ApiForbiddenResponseBody', () => {
|
||||
it('should return api forbidden response decorator with proper settings', () => {
|
||||
ApiForbiddenResponseBody();
|
||||
|
||||
expect(apiForbiddenResponseMock).toHaveBeenCalledWith({
|
||||
type: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
22
src/core/decorators/api/forbidden-response-body.decorator.ts
Normal file
22
src/core/decorators/api/forbidden-response-body.decorator.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiForbiddenResponse, ApiProperty } from '@nestjs/swagger';
|
||||
import { ErrorCategory } from '~/core/enums';
|
||||
import { IFieldError, IResponseError } from '~/core/interfaces';
|
||||
|
||||
/**
|
||||
* Define forbidden (403) response in api endpoint
|
||||
*/
|
||||
export const ApiForbiddenResponseBody = () => {
|
||||
return applyDecorators(ApiForbiddenResponse({ type: ForbiddenError }));
|
||||
};
|
||||
|
||||
class ForbiddenError implements IResponseError {
|
||||
@ApiProperty({ example: ErrorCategory.FORBIDDEN_ERROR })
|
||||
category!: ErrorCategory;
|
||||
|
||||
@ApiProperty({ example: 'Do not have permission to do this action' })
|
||||
message!: string;
|
||||
|
||||
@ApiProperty({ example: [] })
|
||||
errors!: IFieldError[];
|
||||
}
|
6
src/core/decorators/api/index.ts
Normal file
6
src/core/decorators/api/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './data-array-response.decorator';
|
||||
export * from './data-page-response.decorator';
|
||||
export * from './data-response.decorator';
|
||||
export * from './forbidden-response-body.decorator';
|
||||
export * from './lang-request-header.decorator';
|
||||
export * from './unauthorized-response-body.decorator';
|
@ -0,0 +1,21 @@
|
||||
const apiHeaderMock = jest.fn();
|
||||
jest.mock('@nestjs/swagger', () => ({ ApiHeader: apiHeaderMock }));
|
||||
|
||||
import { LANGUAGE_HEADER_NAME } from '~/core/constants';
|
||||
import { UserLocale } from '~/core/enums';
|
||||
import { ApiLangRequestHeader } from './lang-request-header.decorator';
|
||||
|
||||
describe('ApiLangRequestHeader', () => {
|
||||
it('should return api header decorator with proper settings', () => {
|
||||
ApiLangRequestHeader();
|
||||
|
||||
expect(apiHeaderMock).toHaveBeenCalledWith({
|
||||
name: LANGUAGE_HEADER_NAME,
|
||||
schema: {
|
||||
enum: Object.values(UserLocale),
|
||||
default: UserLocale.ENGLISH,
|
||||
example: UserLocale.ARABIC,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
20
src/core/decorators/api/lang-request-header.decorator.ts
Normal file
20
src/core/decorators/api/lang-request-header.decorator.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiHeader } from '@nestjs/swagger';
|
||||
import { LANGUAGE_HEADER_NAME } from '~/core/constants';
|
||||
import { UserLocale } from '~/core/enums';
|
||||
|
||||
/**
|
||||
* Define language request header 'Accept-Language' in api endpoint
|
||||
*/
|
||||
export const ApiLangRequestHeader = () => {
|
||||
return applyDecorators(
|
||||
ApiHeader({
|
||||
name: LANGUAGE_HEADER_NAME,
|
||||
schema: {
|
||||
enum: Object.values(UserLocale),
|
||||
default: UserLocale.ENGLISH,
|
||||
example: UserLocale.ARABIC,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
const apiUnauthorizedResponseMock = jest.fn();
|
||||
jest.mock('@nestjs/swagger', () => ({
|
||||
ApiUnauthorizedResponse: apiUnauthorizedResponseMock,
|
||||
ApiProperty: () => jest.fn(),
|
||||
}));
|
||||
|
||||
import { ApiUnauthorizedResponseBody } from './unauthorized-response-body.decorator';
|
||||
|
||||
describe('ApiUnauthorizedResponseBody', () => {
|
||||
it('should return api unauthorized response decorator with proper settings', () => {
|
||||
ApiUnauthorizedResponseBody();
|
||||
|
||||
expect(apiUnauthorizedResponseMock).toHaveBeenCalledWith({
|
||||
type: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,22 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
||||
import { ErrorCategory } from '~/core/enums';
|
||||
import { IFieldError, IResponseError } from '~/core/interfaces';
|
||||
|
||||
/**
|
||||
* Define unauthorized (401) response in api endpoint
|
||||
*/
|
||||
export const ApiUnauthorizedResponseBody = () => {
|
||||
return applyDecorators(ApiUnauthorizedResponse({ type: UnauthorizedError }));
|
||||
};
|
||||
|
||||
class UnauthorizedError implements IResponseError {
|
||||
@ApiProperty({ example: ErrorCategory.UNAUTHORIZED_ERROR })
|
||||
category!: ErrorCategory;
|
||||
|
||||
@ApiProperty({ example: 'You have to login again' })
|
||||
message!: string;
|
||||
|
||||
@ApiProperty({ example: [] })
|
||||
errors!: IFieldError[];
|
||||
}
|
2
src/core/decorators/index.ts
Normal file
2
src/core/decorators/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
// export * from './validations';
|
1
src/core/decorators/validations/index.ts
Normal file
1
src/core/decorators/validations/index.ts
Normal file
@ -0,0 +1 @@
|
||||
// placeholder
|
2
src/core/dtos/index.ts
Normal file
2
src/core/dtos/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './requests';
|
||||
export * from './responses';
|
1
src/core/dtos/requests/index.ts
Normal file
1
src/core/dtos/requests/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './page-options.request.dto';
|
106
src/core/dtos/requests/page-options.request.dto.spec.ts
Normal file
106
src/core/dtos/requests/page-options.request.dto.spec.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { PageOptionsRequestDto } from './page-options.request.dto';
|
||||
|
||||
describe('PageOptionsRequestDto', () => {
|
||||
describe('page', () => {
|
||||
it('should be optional (default 1)', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, {});
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(pageOptions.page).toEqual(1);
|
||||
});
|
||||
|
||||
it('should transform to number', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, { page: '2' });
|
||||
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(pageOptions.page).toEqual(2);
|
||||
});
|
||||
|
||||
it('should be number only', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, { page: 'abc' });
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors[0].property).toEqual('page');
|
||||
expect(errors[0].constraints).toHaveProperty('isNumber');
|
||||
});
|
||||
|
||||
it('should be an integer only', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, { page: 1.5 });
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors[0].property).toEqual('page');
|
||||
expect(errors[0].constraints).toHaveProperty('isInt');
|
||||
});
|
||||
|
||||
it('should be minimum of 1', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, { page: 0 });
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors[0].property).toEqual('page');
|
||||
expect(errors[0].constraints).toHaveProperty('min');
|
||||
});
|
||||
|
||||
it('should be maximum of 1_000_000_000_000', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: 1_000_000_000_001 });
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors[0].property).toEqual('size');
|
||||
expect(errors[0].constraints).toHaveProperty('max');
|
||||
});
|
||||
});
|
||||
|
||||
describe('size', () => {
|
||||
it('should be optional (default 10)', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, {});
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(pageOptions.size).toEqual(10);
|
||||
});
|
||||
|
||||
it('should transform to number', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: '2' });
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(pageOptions.size).toEqual(2);
|
||||
});
|
||||
|
||||
it('should be number only', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: 'abc' });
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors[0].property).toEqual('size');
|
||||
expect(errors[0].constraints).toHaveProperty('isNumber');
|
||||
});
|
||||
|
||||
it('should be an integer only', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: 1.5 });
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors[0].property).toEqual('size');
|
||||
expect(errors[0].constraints).toHaveProperty('isInt');
|
||||
});
|
||||
|
||||
it('should be minimum of 1', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: 0 });
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors[0].property).toEqual('size');
|
||||
expect(errors[0].constraints).toHaveProperty('min');
|
||||
});
|
||||
|
||||
it('should be maximum of 50', () => {
|
||||
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: 51 });
|
||||
const errors = validateSync(pageOptions);
|
||||
|
||||
expect(errors[0].property).toEqual('size');
|
||||
expect(errors[0].constraints).toHaveProperty('max');
|
||||
});
|
||||
});
|
||||
});
|
46
src/core/dtos/requests/page-options.request.dto.ts
Normal file
46
src/core/dtos/requests/page-options.request.dto.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsInt, IsNumber, Max, Min } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
|
||||
const MIN_PAGINATION_PAGE = 1;
|
||||
const MAX_PAGINATION_PAGE = 10000000;
|
||||
const MIN_PAGINATION_SIZE = 1;
|
||||
const MAX_PAGINATION_SIZE = 50;
|
||||
const DEFAULT_PAGINATION_PAGE = 1;
|
||||
const DEFAULT_PAGINATION_SIZE = 10;
|
||||
|
||||
export class PageOptionsRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
minimum: MIN_PAGINATION_PAGE,
|
||||
default: DEFAULT_PAGINATION_PAGE,
|
||||
example: DEFAULT_PAGINATION_PAGE,
|
||||
description: 'Pagination page',
|
||||
})
|
||||
@Max(MAX_PAGINATION_PAGE, { message: i18n('validation.Max', { path: 'general', property: 'paginationPage' }) })
|
||||
@Min(MIN_PAGINATION_PAGE, { message: i18n('validation.Min', { path: 'general', property: 'paginationPage' }) })
|
||||
@IsInt({ message: i18n('validation.IsInt', { path: 'general', property: 'paginationPage' }) })
|
||||
@IsNumber(
|
||||
{ allowNaN: false },
|
||||
{ message: i18n('validation.IsNumber', { path: 'general', property: 'paginationPage' }) },
|
||||
)
|
||||
@Transform(({ value }) => +value)
|
||||
page: number = DEFAULT_PAGINATION_PAGE;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: MIN_PAGINATION_SIZE,
|
||||
maximum: MAX_PAGINATION_SIZE,
|
||||
default: DEFAULT_PAGINATION_SIZE,
|
||||
example: DEFAULT_PAGINATION_SIZE,
|
||||
description: 'Pagination page size',
|
||||
})
|
||||
@Max(MAX_PAGINATION_SIZE, { message: i18n('validation.Max', { path: 'general', property: 'paginationSize' }) })
|
||||
@Min(MIN_PAGINATION_SIZE, { message: i18n('validation.Min', { path: 'general', property: 'paginationSize' }) })
|
||||
@IsInt({ message: i18n('validation.IsInt', { path: 'general', property: 'paginationSize' }) })
|
||||
@IsNumber(
|
||||
{ allowNaN: false },
|
||||
{ message: i18n('validation.IsNumber', { path: 'general', property: 'paginationSize' }) },
|
||||
)
|
||||
@Transform(({ value }) => +value)
|
||||
size: number = DEFAULT_PAGINATION_SIZE;
|
||||
}
|
13
src/core/dtos/responses/data-array.response.dto.spec.ts
Normal file
13
src/core/dtos/responses/data-array.response.dto.spec.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { DataArrayResponseDto } from './data-array.response.dto';
|
||||
|
||||
describe('DataArrayResponseDto', () => {
|
||||
it('should initialize the DataArrayResponseDto instance correctly', () => {
|
||||
const testData = [{ id: 1 }, { id: 2 }];
|
||||
|
||||
const dataArrayResponse = new DataArrayResponseDto(testData);
|
||||
|
||||
expect(dataArrayResponse).toEqual({
|
||||
data: testData,
|
||||
});
|
||||
});
|
||||
});
|
10
src/core/dtos/responses/data-array.response.dto.ts
Normal file
10
src/core/dtos/responses/data-array.response.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class DataArrayResponseDto<T> {
|
||||
@ApiProperty()
|
||||
readonly data: T[];
|
||||
|
||||
constructor(data: T[]) {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
16
src/core/dtos/responses/data-page.response.dto.spec.ts
Normal file
16
src/core/dtos/responses/data-page.response.dto.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { DataPageResponseDto } from './data-page.response.dto';
|
||||
import { PageMetaResponseDto } from './page-meta.response.dto';
|
||||
|
||||
describe('DataPageResponseDto', () => {
|
||||
it('should initialize the DataPageResponseDto instance correctly', () => {
|
||||
const testData = [{ id: 1 }, { id: 2 }];
|
||||
const testMeta = new PageMetaResponseDto({ page: 1, size: 2, itemCount: 5 });
|
||||
|
||||
const dataPage = new DataPageResponseDto(testData, testMeta);
|
||||
|
||||
expect(dataPage).toEqual({
|
||||
data: testData,
|
||||
meta: testMeta,
|
||||
});
|
||||
});
|
||||
});
|
13
src/core/dtos/responses/data-page.response.dto.ts
Normal file
13
src/core/dtos/responses/data-page.response.dto.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { DataArrayResponseDto } from './data-array.response.dto';
|
||||
import { PageMetaResponseDto } from './page-meta.response.dto';
|
||||
|
||||
export class DataPageResponseDto<T> extends DataArrayResponseDto<T> {
|
||||
@ApiProperty({ type: PageMetaResponseDto })
|
||||
readonly meta: PageMetaResponseDto;
|
||||
|
||||
constructor(data: T[], meta: PageMetaResponseDto) {
|
||||
super(data);
|
||||
this.meta = meta;
|
||||
}
|
||||
}
|
13
src/core/dtos/responses/data.response.dto.spec.ts
Normal file
13
src/core/dtos/responses/data.response.dto.spec.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { DataResponseDto } from './data.response.dto';
|
||||
|
||||
describe('DataResponseDto', () => {
|
||||
it('should initialize the DataResponseDto instance correctly', () => {
|
||||
const testData = { foo: 'bar' };
|
||||
|
||||
const dataResponse = new DataResponseDto(testData);
|
||||
|
||||
expect(dataResponse).toEqual({
|
||||
data: testData,
|
||||
});
|
||||
});
|
||||
});
|
10
src/core/dtos/responses/data.response.dto.ts
Normal file
10
src/core/dtos/responses/data.response.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class DataResponseDto<T> {
|
||||
@ApiProperty()
|
||||
readonly data: T;
|
||||
|
||||
constructor(data: T) {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
4
src/core/dtos/responses/index.ts
Normal file
4
src/core/dtos/responses/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './data-array.response.dto';
|
||||
export * from './data-page.response.dto';
|
||||
export * from './data.response.dto';
|
||||
export * from './page-meta.response.dto';
|
55
src/core/dtos/responses/page-meta.response.dto.spec.ts
Normal file
55
src/core/dtos/responses/page-meta.response.dto.spec.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { PageMetaResponseDto } from './page-meta.response.dto';
|
||||
|
||||
describe('PageMetaResponseDto', () => {
|
||||
it('should initialize the PageMetaResponseDto instance correctly', () => {
|
||||
const pageMeta = new PageMetaResponseDto({ page: 1, size: 10, itemCount: 15 });
|
||||
|
||||
expect(pageMeta).toEqual({
|
||||
page: 1,
|
||||
size: 10,
|
||||
itemCount: 15,
|
||||
pageCount: 2,
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set hasPreviousPage and hasNextPage properly', () => {
|
||||
const pageMeta = new PageMetaResponseDto({ page: 2, size: 10, itemCount: 15 });
|
||||
|
||||
expect(pageMeta).toEqual({
|
||||
page: 2,
|
||||
size: 10,
|
||||
itemCount: 15,
|
||||
pageCount: 2,
|
||||
hasPreviousPage: true,
|
||||
hasNextPage: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set hasNextPage to false if page is equal to pageCount', () => {
|
||||
const pageMeta = new PageMetaResponseDto({ page: 2, size: 7, itemCount: 14 });
|
||||
|
||||
expect(pageMeta).toEqual({
|
||||
page: 2,
|
||||
size: 7,
|
||||
itemCount: 14,
|
||||
pageCount: 2,
|
||||
hasPreviousPage: true,
|
||||
hasNextPage: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle zero itemCount correctly', () => {
|
||||
const pageMeta = new PageMetaResponseDto({ page: 1, size: 10, itemCount: 0 });
|
||||
|
||||
expect(pageMeta).toEqual({
|
||||
page: 1,
|
||||
size: 10,
|
||||
itemCount: 0,
|
||||
pageCount: 0,
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
});
|
||||
});
|
||||
});
|
38
src/core/dtos/responses/page-meta.response.dto.ts
Normal file
38
src/core/dtos/responses/page-meta.response.dto.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
const MIN_PAGE = 1;
|
||||
|
||||
export interface IPageMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export class PageMetaResponseDto {
|
||||
@ApiProperty({ example: 1 })
|
||||
readonly page: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
readonly size: number;
|
||||
|
||||
@ApiProperty({ example: 15 })
|
||||
readonly itemCount: number;
|
||||
|
||||
@ApiProperty({ example: 2 })
|
||||
readonly pageCount: number;
|
||||
|
||||
@ApiProperty({ example: false })
|
||||
readonly hasPreviousPage: boolean;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
readonly hasNextPage: boolean;
|
||||
|
||||
constructor({ page, size, itemCount }: IPageMeta) {
|
||||
this.page = +page;
|
||||
this.size = +size;
|
||||
this.itemCount = +itemCount;
|
||||
this.pageCount = Math.ceil(this.itemCount / this.size);
|
||||
this.hasPreviousPage = this.page > MIN_PAGE;
|
||||
this.hasNextPage = this.page < this.pageCount;
|
||||
}
|
||||
}
|
6
src/core/enums/environment.enum.ts
Normal file
6
src/core/enums/environment.enum.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum Environment {
|
||||
DEV = 'dev',
|
||||
TEST = 'test',
|
||||
STAGING = 'staging',
|
||||
PROD = 'prod',
|
||||
}
|
9
src/core/enums/error-category.enum.ts
Normal file
9
src/core/enums/error-category.enum.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export enum ErrorCategory {
|
||||
UNAUTHORIZED_ERROR = 'UNAUTHORIZED_ERROR',
|
||||
FORBIDDEN_ERROR = 'FORBIDDEN_ERROR',
|
||||
NOT_FOUND_ERROR = 'NOT_FOUND_ERROR',
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
BUSINESS_ERROR = 'BUSINESS_ERROR',
|
||||
UNHANDLED_ERROR = 'UNHANDLED_ERROR',
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
}
|
3
src/core/enums/index.ts
Normal file
3
src/core/enums/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './environment.enum';
|
||||
export * from './error-category.enum';
|
||||
export * from './user-locale.enum';
|
4
src/core/enums/user-locale.enum.ts
Normal file
4
src/core/enums/user-locale.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum UserLocale {
|
||||
ARABIC = 'ar',
|
||||
ENGLISH = 'en',
|
||||
}
|
36
src/core/exceptions/custom-rpc.exception.spec.ts
Normal file
36
src/core/exceptions/custom-rpc.exception.spec.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { CustomRpcException, IRpcError } from './custom-rpc.exception';
|
||||
|
||||
describe('CustomRpcException', () => {
|
||||
describe('getError', () => {
|
||||
it('should return the RPC error if available', () => {
|
||||
const exception = {
|
||||
rpcError: {
|
||||
message: 'RPC Error Message',
|
||||
status: 500,
|
||||
},
|
||||
};
|
||||
|
||||
const customException = new CustomRpcException(exception);
|
||||
const result = customException.getError();
|
||||
|
||||
expect(result).toEqual(exception.rpcError);
|
||||
});
|
||||
|
||||
it('should create a new RPC error if none provided', () => {
|
||||
const exception = {
|
||||
message: 'Error Message',
|
||||
status: 404,
|
||||
};
|
||||
|
||||
const customException = new CustomRpcException(exception);
|
||||
const result = customException.getError();
|
||||
|
||||
const expected: IRpcError = {
|
||||
message: exception.message,
|
||||
status: exception.status,
|
||||
};
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
22
src/core/exceptions/custom-rpc.exception.ts
Normal file
22
src/core/exceptions/custom-rpc.exception.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export interface IRpcError {
|
||||
message: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export class CustomRpcException {
|
||||
private readonly rpcError: IRpcError;
|
||||
|
||||
constructor(exception: any) {
|
||||
this.rpcError =
|
||||
(exception.rpcError as IRpcError) || // propagate RPC error if any
|
||||
({
|
||||
message: exception.message,
|
||||
status: exception.status,
|
||||
} as IRpcError);
|
||||
}
|
||||
|
||||
// for testing
|
||||
public getError(): IRpcError {
|
||||
return this.rpcError;
|
||||
}
|
||||
}
|
1
src/core/exceptions/index.ts
Normal file
1
src/core/exceptions/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './custom-rpc.exception';
|
384
src/core/filters/all-exceptions.filter.spec.ts
Normal file
384
src/core/filters/all-exceptions.filter.spec.ts
Normal file
@ -0,0 +1,384 @@
|
||||
const i18nTMock = jest.fn();
|
||||
const i18nTsMock = jest.fn();
|
||||
const I18nContextWrapperMock = jest.fn().mockReturnValue({
|
||||
t: i18nTMock,
|
||||
ts: i18nTsMock,
|
||||
});
|
||||
jest.mock('../utils/i18n-context-wrapper.util', () => ({
|
||||
I18nContextWrapper: I18nContextWrapperMock,
|
||||
}));
|
||||
|
||||
import { DeepMocked, createMock } from '@golevelup/ts-jest';
|
||||
import {
|
||||
ArgumentsHost,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Response as ExpressResponse } from 'express';
|
||||
import { ErrorCategory } from '../enums';
|
||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||
|
||||
const LOG_CONTEXT = 'AllExceptionsFilter';
|
||||
|
||||
describe('AllExceptionsFilter', () => {
|
||||
let filter: AllExceptionsFilter;
|
||||
|
||||
let loggerMock: DeepMocked<Logger>;
|
||||
let responseMock: DeepMocked<ExpressResponse>;
|
||||
let hostMock: DeepMocked<ArgumentsHost>;
|
||||
let getRequestMock: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
loggerMock = createMock<Logger>();
|
||||
getRequestMock = jest.fn().mockReturnValue({ originalUrl: '/test' });
|
||||
responseMock = createMock<ExpressResponse>({
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
send: jest.fn(),
|
||||
});
|
||||
hostMock = createMock<ArgumentsHost>({
|
||||
getType: jest.fn().mockReturnValue('http'),
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getResponse: jest.fn().mockReturnValue(responseMock),
|
||||
getRequest: getRequestMock,
|
||||
}),
|
||||
});
|
||||
i18nTMock.mockImplementation((key: string) => `LOCALIZED_${key}`);
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [AllExceptionsFilter],
|
||||
}).compile();
|
||||
moduleRef.useLogger(loggerMock);
|
||||
|
||||
filter = moduleRef.get<AllExceptionsFilter>(AllExceptionsFilter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Known Http Exceptions', () => {
|
||||
it.each([
|
||||
{
|
||||
exception: new UnauthorizedException(),
|
||||
category: ErrorCategory.UNAUTHORIZED_ERROR,
|
||||
expectedStatus: HttpStatus.UNAUTHORIZED,
|
||||
expectedCategory: ErrorCategory.UNAUTHORIZED_ERROR,
|
||||
expectedMessage: ErrorCategory.UNAUTHORIZED_ERROR,
|
||||
},
|
||||
{
|
||||
exception: new ForbiddenException(),
|
||||
category: ErrorCategory.FORBIDDEN_ERROR,
|
||||
expectedStatus: HttpStatus.FORBIDDEN,
|
||||
expectedCategory: ErrorCategory.FORBIDDEN_ERROR,
|
||||
expectedMessage: ErrorCategory.FORBIDDEN_ERROR,
|
||||
},
|
||||
{
|
||||
exception: new NotFoundException(),
|
||||
category: ErrorCategory.NOT_FOUND_ERROR,
|
||||
expectedStatus: HttpStatus.NOT_FOUND,
|
||||
expectedCategory: ErrorCategory.NOT_FOUND_ERROR,
|
||||
expectedMessage: ErrorCategory.NOT_FOUND_ERROR,
|
||||
},
|
||||
{
|
||||
exception: new InternalServerErrorException(),
|
||||
category: ErrorCategory.INTERNAL_SERVER_ERROR,
|
||||
expectedStatus: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
expectedCategory: ErrorCategory.INTERNAL_SERVER_ERROR,
|
||||
expectedMessage: ErrorCategory.INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
{
|
||||
exception: new NotFoundException({
|
||||
category: ErrorCategory.BUSINESS_ERROR,
|
||||
message: 'CUSTOM_ERROR',
|
||||
}),
|
||||
category: ErrorCategory.NOT_FOUND_ERROR,
|
||||
expectedStatus: HttpStatus.NOT_FOUND,
|
||||
expectedCategory: ErrorCategory.BUSINESS_ERROR,
|
||||
expectedMessage: 'LOCALIZED_CUSTOM_ERROR',
|
||||
},
|
||||
])(
|
||||
'should return error response with "$category" category in case of $exception',
|
||||
({ exception, category, expectedStatus, expectedCategory, expectedMessage }) => {
|
||||
i18nTsMock.mockImplementationOnce((...keys: string[]) =>
|
||||
keys.some((key) => key === 'CUSTOM_ERROR') ? 'LOCALIZED_CUSTOM_ERROR' : keys.pop(),
|
||||
);
|
||||
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(expectedStatus);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: expectedCategory,
|
||||
message: expectedMessage,
|
||||
errors: [],
|
||||
});
|
||||
expect(i18nTsMock).toHaveBeenCalledWith(exception.message, category);
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Unknown Exceptions/Errors', () => {
|
||||
it('should return error response with "UNHANDLED_ERROR" category in case of unknown http exceptions', () => {
|
||||
const msg = 'test message';
|
||||
const exception = new BadRequestException(msg);
|
||||
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: ErrorCategory.UNHANDLED_ERROR,
|
||||
message: `LOCALIZED_${msg}`,
|
||||
errors: [],
|
||||
});
|
||||
expect(i18nTMock).toHaveBeenCalledWith(msg);
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
});
|
||||
|
||||
it('should return error response with "INTERNAL_SERVER_ERROR" category in case of unknown errors', () => {
|
||||
i18nTsMock.mockReturnValueOnce('XYZ');
|
||||
const msg = 'test error';
|
||||
const exception = new Error(msg);
|
||||
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: ErrorCategory.INTERNAL_SERVER_ERROR,
|
||||
message: 'XYZ',
|
||||
errors: [],
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
});
|
||||
|
||||
it('should return error response with "VALIDATION_ERROR" category in case of request body parsing errors', () => {
|
||||
const msg = 'Unexpected end of JSON input';
|
||||
const exception = new BadRequestException(msg);
|
||||
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: ErrorCategory.VALIDATION_ERROR,
|
||||
message: `LOCALIZED_${msg}`,
|
||||
errors: [],
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
});
|
||||
|
||||
it('should return error response with passed category', () => {
|
||||
const msg = 'Business error';
|
||||
const exception = new UnprocessableEntityException({
|
||||
category: ErrorCategory.BUSINESS_ERROR,
|
||||
message: msg,
|
||||
});
|
||||
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: ErrorCategory.BUSINESS_ERROR,
|
||||
message: `LOCALIZED_${msg}`,
|
||||
errors: [],
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
});
|
||||
|
||||
it('should return error response with default category if not passed', () => {
|
||||
const exception = { status: HttpStatus.CONFLICT };
|
||||
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: ErrorCategory.UNHANDLED_ERROR,
|
||||
message: `LOCALIZED_${undefined}`,
|
||||
errors: [],
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
});
|
||||
|
||||
it('should return error response with list of passed errors (localized)', () => {
|
||||
const msg = 'test message';
|
||||
const field = 'name';
|
||||
const fieldError = 'IsEmpty';
|
||||
const exception = new BadRequestException({
|
||||
message: msg,
|
||||
errors: [{ field, message: fieldError }],
|
||||
});
|
||||
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: ErrorCategory.UNHANDLED_ERROR,
|
||||
message: `LOCALIZED_${msg}`,
|
||||
errors: [{ field, message: `LOCALIZED_${fieldError}` }],
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
});
|
||||
|
||||
it('should return extracted category even if it was parsing error in case of unknown i18n value', () => {
|
||||
const msg = 'Unexpected end of JSON input';
|
||||
const exception = new BadRequestException(msg);
|
||||
i18nTMock.mockReturnValueOnce(undefined);
|
||||
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: ErrorCategory.UNHANDLED_ERROR,
|
||||
errors: [],
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback', () => {
|
||||
it('should fallback to a proper error response in case of error in handling the exception', () => {
|
||||
const error = new Error('INTERNAL_SERVER_ERROR');
|
||||
jest.spyOn(responseMock, 'json').mockImplementationOnce(() => {
|
||||
throw error;
|
||||
});
|
||||
const exception = new BadRequestException();
|
||||
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: ErrorCategory.INTERNAL_SERVER_ERROR,
|
||||
message: `LOCALIZED_${ErrorCategory.INTERNAL_SERVER_ERROR}`,
|
||||
errors: [],
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(error, undefined, LOG_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extract exception status/message', () => {
|
||||
describe('Status Code', () => {
|
||||
it.each([
|
||||
{
|
||||
path: 'exception.response?.status',
|
||||
exception: { response: { status: HttpStatus.SERVICE_UNAVAILABLE } },
|
||||
},
|
||||
{
|
||||
path: 'exception.response?.statusCode',
|
||||
exception: { response: { statusCode: HttpStatus.SERVICE_UNAVAILABLE } },
|
||||
},
|
||||
{
|
||||
path: 'exception.status',
|
||||
exception: { status: HttpStatus.SERVICE_UNAVAILABLE },
|
||||
},
|
||||
])('$path', ({ exception }) => {
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: ErrorCategory.UNHANDLED_ERROR,
|
||||
message: `LOCALIZED_undefined`,
|
||||
errors: [],
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message', () => {
|
||||
it.each([
|
||||
{
|
||||
path: 'exception.response?.data?.data?.error',
|
||||
exception: {
|
||||
response: { status: HttpStatus.SERVICE_UNAVAILABLE, data: { data: { error: 'ERROR_MSG' } } },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'exception.response?.data?.error',
|
||||
exception: {
|
||||
response: { status: HttpStatus.SERVICE_UNAVAILABLE, data: { error: 'ERROR_MSG' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'exception.response?.data?.errorMessage',
|
||||
exception: {
|
||||
response: { status: HttpStatus.SERVICE_UNAVAILABLE, data: { errorMessage: 'ERROR_MSG' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'exception.response?.message',
|
||||
exception: {
|
||||
response: { status: HttpStatus.SERVICE_UNAVAILABLE, message: 'ERROR_MSG' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'exception.response?.error',
|
||||
exception: {
|
||||
response: { status: HttpStatus.SERVICE_UNAVAILABLE, error: 'ERROR_MSG' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'exception.message',
|
||||
exception: { status: HttpStatus.SERVICE_UNAVAILABLE, message: 'ERROR_MSG' },
|
||||
},
|
||||
])('$path', ({ exception }) => {
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
path: 'exception.response?.message (null message)',
|
||||
exception: {
|
||||
response: { status: HttpStatus.SERVICE_UNAVAILABLE, message: null },
|
||||
},
|
||||
expectedMsg: 'LOCALIZED_null',
|
||||
},
|
||||
{
|
||||
path: 'exception.response?.message (undefined message)',
|
||||
exception: {
|
||||
response: { status: HttpStatus.SERVICE_UNAVAILABLE, message: undefined },
|
||||
},
|
||||
expectedMsg: 'LOCALIZED_undefined',
|
||||
},
|
||||
{
|
||||
path: 'exception.response?.message (0 message)',
|
||||
exception: {
|
||||
response: { status: HttpStatus.SERVICE_UNAVAILABLE, message: 0 },
|
||||
},
|
||||
expectedMsg: 'LOCALIZED_0',
|
||||
},
|
||||
{
|
||||
path: 'exception.response?.message (undefined message)',
|
||||
exception: {
|
||||
response: { status: HttpStatus.SERVICE_UNAVAILABLE, message: false },
|
||||
},
|
||||
expectedMsg: `LOCALIZED_false`,
|
||||
},
|
||||
{
|
||||
path: 'exception.response?.message ("" message)',
|
||||
exception: {
|
||||
response: { status: HttpStatus.SERVICE_UNAVAILABLE, message: '' },
|
||||
},
|
||||
expectedMsg: `LOCALIZED_`,
|
||||
},
|
||||
])('$path', ({ exception, expectedMsg }) => {
|
||||
filter.catch(exception, hostMock);
|
||||
|
||||
expect(responseMock.status).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
expect(responseMock.json).toHaveBeenCalledWith({
|
||||
category: ErrorCategory.UNHANDLED_ERROR,
|
||||
message: expectedMsg,
|
||||
errors: [],
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledWith(exception, undefined, LOG_CONTEXT);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
86
src/core/filters/all-exceptions.filter.ts
Normal file
86
src/core/filters/all-exceptions.filter.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { HttpArgumentsHost } from '@nestjs/common/interfaces';
|
||||
import { Response as ExpressResponse } from 'express';
|
||||
import { I18nContext } from 'nestjs-i18n';
|
||||
import { ErrorCategory } from '../enums';
|
||||
import { IFieldError } from '../interfaces';
|
||||
import { I18nContextWrapper, ResponseFactory } from '../utils';
|
||||
|
||||
const KNOWN_HTTP_EXCEPTION_CATEGORIES: { [_: number]: ErrorCategory } = {
|
||||
[HttpStatus.UNAUTHORIZED]: ErrorCategory.UNAUTHORIZED_ERROR,
|
||||
[HttpStatus.FORBIDDEN]: ErrorCategory.FORBIDDEN_ERROR,
|
||||
[HttpStatus.NOT_FOUND]: ErrorCategory.NOT_FOUND_ERROR,
|
||||
[HttpStatus.INTERNAL_SERVER_ERROR]: ErrorCategory.INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
|
||||
catch(exception: any, host: ArgumentsHost) {
|
||||
this.logger.error(exception);
|
||||
|
||||
// Exception in HTTP context
|
||||
const httpCtx: HttpArgumentsHost = host.switchToHttp();
|
||||
const res = httpCtx.getResponse<ExpressResponse>();
|
||||
const i18n = new I18nContextWrapper(I18nContext.current());
|
||||
|
||||
try {
|
||||
const status = this.extractStatusCode(exception);
|
||||
|
||||
// Known categories (default errors, e.g. not found, unauthorized, ...)
|
||||
const knownCategory = KNOWN_HTTP_EXCEPTION_CATEGORIES[`${status}`];
|
||||
if (knownCategory) {
|
||||
const category = exception.response?.category || knownCategory;
|
||||
const message = i18n.ts(exception.response?.message, knownCategory);
|
||||
return res.status(status).json(ResponseFactory.error(category, message));
|
||||
}
|
||||
|
||||
// Unknown Exception
|
||||
const category = exception.response?.category || ErrorCategory.UNHANDLED_ERROR;
|
||||
const message = this.extractMessage(exception);
|
||||
const errors = Array.isArray(exception.response?.errors) ? exception.response.errors : [];
|
||||
|
||||
const resMessage = i18n.t(message);
|
||||
const resCategory = resMessage?.toString().includes('JSON') // Request body parsing error
|
||||
? ErrorCategory.VALIDATION_ERROR
|
||||
: category;
|
||||
const resErrors = errors.map(
|
||||
(error: IFieldError) => ({ field: error.field, message: i18n.t(error.message) }) as IFieldError,
|
||||
);
|
||||
|
||||
return res.status(status).json(ResponseFactory.error(resCategory, resMessage, resErrors));
|
||||
} catch (err) {
|
||||
// Fallback
|
||||
this.logger.error(err);
|
||||
return res
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.json(ResponseFactory.error(ErrorCategory.INTERNAL_SERVER_ERROR, i18n.t(ErrorCategory.INTERNAL_SERVER_ERROR)));
|
||||
}
|
||||
}
|
||||
|
||||
private extractStatusCode(exception: any): number {
|
||||
return +(
|
||||
exception.getStatus?.() || // NestJS HttpException
|
||||
exception.response?.status ||
|
||||
exception.response?.statusCode ||
|
||||
exception.status ||
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
private extractMessage(exception: any): any {
|
||||
const messages = [
|
||||
exception.response?.data?.errorMessage,
|
||||
exception.response?.message, // NestJS HttpException
|
||||
exception.response?.error,
|
||||
exception.message,
|
||||
];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message !== undefined) return message; // will accept falsy values except undefined (e.g. null, '', 0, ...)
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
38
src/core/filters/i18n-validation-exceptions.filter.spec.ts
Normal file
38
src/core/filters/i18n-validation-exceptions.filter.spec.ts
Normal file
@ -0,0 +1,38 @@
|
||||
const formatClassValidatorErrorsMock = jest.fn();
|
||||
jest.mock('~/core/utils/class-validator-formatter.util', () => ({
|
||||
formatClassValidatorErrors: formatClassValidatorErrorsMock,
|
||||
}));
|
||||
|
||||
const I18nValidationExceptionFilterMock = jest.fn();
|
||||
jest.mock('nestjs-i18n/dist/filters/i18n-validation-exception.filter', () => ({
|
||||
I18nValidationExceptionFilter: I18nValidationExceptionFilterMock,
|
||||
}));
|
||||
|
||||
import { createMock } from '@golevelup/ts-jest';
|
||||
import { ValidationError } from '@nestjs/common';
|
||||
import { buildI18nValidationExceptionFilter } from './i18n-validation-exceptions.filter';
|
||||
|
||||
describe('I18nValidationExceptionFilter', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should build an instance of I18nValidationExceptionFilter configured properly', () => {
|
||||
const filter = buildI18nValidationExceptionFilter();
|
||||
|
||||
expect(filter).toBeInstanceOf(I18nValidationExceptionFilterMock);
|
||||
expect(I18nValidationExceptionFilterMock).toBeCalledWith({
|
||||
errorFormatter: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should build an instance of I18nValidationExceptionFilter configured properly', () => {
|
||||
I18nValidationExceptionFilterMock.mockImplementationOnce((props) => props);
|
||||
const mockValidationError = createMock<ValidationError>();
|
||||
|
||||
const filter = buildI18nValidationExceptionFilter() as any;
|
||||
filter.errorFormatter([mockValidationError]);
|
||||
|
||||
expect(formatClassValidatorErrorsMock).toHaveBeenCalledWith([mockValidationError]);
|
||||
});
|
||||
});
|
9
src/core/filters/i18n-validation-exceptions.filter.ts
Normal file
9
src/core/filters/i18n-validation-exceptions.filter.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { ValidationError } from '@nestjs/common';
|
||||
import { I18nValidationExceptionFilter } from 'nestjs-i18n';
|
||||
import { formatClassValidatorErrors } from '~/core/utils';
|
||||
|
||||
export function buildI18nValidationExceptionFilter(): I18nValidationExceptionFilter {
|
||||
return new I18nValidationExceptionFilter({
|
||||
errorFormatter: (errors: ValidationError[]) => formatClassValidatorErrors(errors),
|
||||
});
|
||||
}
|
2
src/core/filters/index.ts
Normal file
2
src/core/filters/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './all-exceptions.filter';
|
||||
export * from './i18n-validation-exceptions.filter';
|
12
src/core/interfaces/errors.interface.ts
Normal file
12
src/core/interfaces/errors.interface.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { ErrorCategory } from '../enums';
|
||||
|
||||
export interface IFieldError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface IResponseError {
|
||||
category: ErrorCategory;
|
||||
message: string;
|
||||
errors?: IFieldError[];
|
||||
}
|
1
src/core/interfaces/index.ts
Normal file
1
src/core/interfaces/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './errors.interface';
|
40
src/core/module-options/config-options.spec.ts
Normal file
40
src/core/module-options/config-options.spec.ts
Normal file
@ -0,0 +1,40 @@
|
||||
// config-options.spec.ts
|
||||
|
||||
import { buildConfigOptions } from './config-options';
|
||||
import * as path from 'path';
|
||||
import { ConfigModuleOptions } from '@nestjs/config';
|
||||
import { Environment } from '../enums';
|
||||
|
||||
describe('buildConfigOptions', () => {
|
||||
const baseConfigDir = path.join(__dirname, '..', '..', 'config');
|
||||
|
||||
it('should return global config options with .env and development environment file path', () => {
|
||||
process.env.NODE_ENV = Environment.DEV;
|
||||
|
||||
const configOptions: ConfigModuleOptions = buildConfigOptions();
|
||||
|
||||
expect(configOptions.isGlobal).toBe(true);
|
||||
expect(configOptions.envFilePath).toEqual(['.env', path.join(baseConfigDir, Environment.DEV + '.env')]);
|
||||
});
|
||||
|
||||
it('should return global config options with .env and production environment file path', () => {
|
||||
process.env.NODE_ENV = Environment.PROD;
|
||||
|
||||
const configOptions: ConfigModuleOptions = buildConfigOptions();
|
||||
|
||||
expect(configOptions.isGlobal).toBe(true);
|
||||
expect(configOptions.envFilePath).toEqual(['.env', path.join(baseConfigDir, Environment.PROD + '.env')]);
|
||||
});
|
||||
|
||||
it('should use default env file if NODE_ENV is not set', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
|
||||
const configOptions: ConfigModuleOptions = buildConfigOptions();
|
||||
|
||||
expect(configOptions.isGlobal).toBe(true);
|
||||
expect(configOptions.envFilePath).toEqual([
|
||||
'.env',
|
||||
path.join(baseConfigDir, 'undefined.env'), // This simulates no NODE_ENV
|
||||
]);
|
||||
});
|
||||
});
|
15
src/core/module-options/config-options.ts
Normal file
15
src/core/module-options/config-options.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ConfigModuleOptions } from '@nestjs/config';
|
||||
import * as path from 'path';
|
||||
|
||||
const baseConfigDir = path.join(__dirname, '..', '..', 'config');
|
||||
|
||||
export function buildConfigOptions(): ConfigModuleOptions {
|
||||
return {
|
||||
isGlobal: true,
|
||||
envFilePath: [
|
||||
// Dev (sensitive + overridden default env vars)
|
||||
'.env',
|
||||
path.join(baseConfigDir, `${process.env.NODE_ENV}.env`),
|
||||
],
|
||||
};
|
||||
}
|
28
src/core/module-options/i18n-options.spec.ts
Normal file
28
src/core/module-options/i18n-options.spec.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { buildI18nOptions } from './i18n-options';
|
||||
import { AcceptLanguageResolver, I18nJsonLoader, QueryResolver, I18nOptions } from 'nestjs-i18n';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('buildI18nOptions', () => {
|
||||
const baseI18nDir = path.join(__dirname, '..', '..', 'i18n');
|
||||
|
||||
it('should return correct I18nOptions configuration', () => {
|
||||
const i18nOptions: I18nOptions = buildI18nOptions();
|
||||
|
||||
// Assert fallbackLanguage
|
||||
expect(i18nOptions.fallbackLanguage).toBe('en');
|
||||
|
||||
// Assert loader and loader options
|
||||
expect(i18nOptions.loader).toBe(I18nJsonLoader);
|
||||
expect(i18nOptions.loaderOptions).toEqual({
|
||||
path: baseI18nDir,
|
||||
});
|
||||
|
||||
// Assert resolvers
|
||||
expect(i18nOptions.resolvers).toHaveLength(2);
|
||||
expect(i18nOptions.resolvers?.[0]).toBeInstanceOf(AcceptLanguageResolver);
|
||||
expect(i18nOptions.resolvers?.[1]).toBeInstanceOf(QueryResolver);
|
||||
// Additional checks for resolver configuration
|
||||
const queryResolver = i18nOptions.resolvers?.[1] as QueryResolver;
|
||||
expect(queryResolver['keys']).toEqual(['lang']); // Verify that 'lang' is the query parameter
|
||||
});
|
||||
});
|
16
src/core/module-options/i18n-options.ts
Normal file
16
src/core/module-options/i18n-options.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { AcceptLanguageResolver, I18nJsonLoader, I18nOptions, QueryResolver } from 'nestjs-i18n';
|
||||
import * as path from 'path';
|
||||
|
||||
const LANG_QUERY_PARAM = 'lang';
|
||||
const baseI18nDir = path.join(__dirname, '..', '..', 'i18n');
|
||||
|
||||
export function buildI18nOptions(): I18nOptions {
|
||||
return {
|
||||
fallbackLanguage: 'en',
|
||||
loaderOptions: {
|
||||
path: baseI18nDir,
|
||||
},
|
||||
loader: I18nJsonLoader,
|
||||
resolvers: [new AcceptLanguageResolver({ matchType: 'strict' }), new QueryResolver([LANG_QUERY_PARAM])],
|
||||
};
|
||||
}
|
3
src/core/module-options/index.ts
Normal file
3
src/core/module-options/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './config-options';
|
||||
export * from './typeorm-options';
|
||||
export * from './logger-options';
|
48
src/core/module-options/logger-options.spec.ts
Normal file
48
src/core/module-options/logger-options.spec.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { createMock } from '@golevelup/ts-jest';
|
||||
import { buildLoggerOptions } from './logger-options';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Params } from 'nestjs-pino';
|
||||
|
||||
describe('buildLoggerOptions', () => {
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = createMock<ConfigService>();
|
||||
});
|
||||
|
||||
it('should return correct logger options with default log level "info"', () => {
|
||||
configService.get.mockReturnValue('info');
|
||||
|
||||
const loggerOptions: Params = buildLoggerOptions(configService);
|
||||
|
||||
expect(loggerOptions).toEqual({
|
||||
pinoHttp: {
|
||||
level: 'info',
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct logger options with custom log level', () => {
|
||||
configService.get.mockReturnValue('debug');
|
||||
|
||||
const loggerOptions: Params = buildLoggerOptions(configService);
|
||||
|
||||
expect(loggerOptions).toEqual({
|
||||
pinoHttp: {
|
||||
level: 'debug',
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
16
src/core/module-options/logger-options.ts
Normal file
16
src/core/module-options/logger-options.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Params } from 'nestjs-pino';
|
||||
|
||||
export function buildLoggerOptions(configService: ConfigService): Params {
|
||||
return {
|
||||
pinoHttp: {
|
||||
level: configService.get('LOG_LEVEL', 'info'),
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
66
src/core/module-options/typeorm-options.spec.ts
Normal file
66
src/core/module-options/typeorm-options.spec.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { buildTypeormOptions } from './typeorm-options';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { Environment } from '../enums';
|
||||
import { DeepMocked, createMock } from '@golevelup/ts-jest';
|
||||
|
||||
describe('buildTypeormOptions', () => {
|
||||
let configService: DeepMocked<ConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = createMock<ConfigService>();
|
||||
|
||||
jest.spyOn(configService, 'getOrThrow').mockImplementation((key) => {
|
||||
const envMap = {
|
||||
DB_HOST: 'localhost',
|
||||
DB_PORT: '5432',
|
||||
DB_USER: 'testuser',
|
||||
DB_PASS: 'testpass',
|
||||
DB_NAME: 'testdb',
|
||||
NODE_ENV: Environment.DEV,
|
||||
MIGRATIONS_RUN: 'true',
|
||||
};
|
||||
return envMap[key];
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct TypeOrmModuleOptions with provided migrations', () => {
|
||||
const migrationsMock = ['src/migrations/*.ts'];
|
||||
|
||||
const options: TypeOrmModuleOptions = buildTypeormOptions(configService, migrationsMock);
|
||||
|
||||
expect(options).toEqual({
|
||||
type: 'postgres',
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
database: 'testdb',
|
||||
logging: true,
|
||||
synchronize: false,
|
||||
migrationsRun: true,
|
||||
autoLoadEntities: true,
|
||||
migrations: migrationsMock,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set logging to false when NODE_ENV is not DEV', () => {
|
||||
jest
|
||||
.spyOn(configService, 'getOrThrow')
|
||||
.mockImplementation((key: string) => (key === 'NODE_ENV' ? Environment.PROD : undefined));
|
||||
|
||||
const options: TypeOrmModuleOptions = buildTypeormOptions(configService);
|
||||
|
||||
expect(options.logging).toBe(false);
|
||||
});
|
||||
|
||||
it('should set migrationsRun to false when MIGRATIONS_RUN is not "true"', () => {
|
||||
jest
|
||||
.spyOn(configService, 'getOrThrow')
|
||||
.mockImplementation((key: string) => (key === 'MIGRATIONS_RUN' ? 'false' : undefined));
|
||||
|
||||
const options: TypeOrmModuleOptions = buildTypeormOptions(configService);
|
||||
|
||||
expect(options.migrationsRun).toBe(false);
|
||||
});
|
||||
});
|
22
src/core/module-options/typeorm-options.ts
Normal file
22
src/core/module-options/typeorm-options.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { Environment } from '../enums';
|
||||
|
||||
export function buildTypeormOptions(
|
||||
config: ConfigService,
|
||||
migrations?: TypeOrmModuleOptions['migrations'],
|
||||
): TypeOrmModuleOptions {
|
||||
return {
|
||||
type: 'postgres',
|
||||
host: config.getOrThrow('DB_HOST'),
|
||||
port: +config.getOrThrow('DB_PORT'),
|
||||
username: config.getOrThrow('DB_USER'),
|
||||
password: config.getOrThrow('DB_PASS'),
|
||||
database: config.getOrThrow('DB_NAME'),
|
||||
logging: config.getOrThrow<string>('NODE_ENV') === Environment.DEV,
|
||||
synchronize: false,
|
||||
migrationsRun: config.getOrThrow<string>('MIGRATIONS_RUN') === 'true',
|
||||
autoLoadEntities: true,
|
||||
migrations,
|
||||
};
|
||||
}
|
19
src/core/pipes/custom-parse-uuid.pipe.spec.ts
Normal file
19
src/core/pipes/custom-parse-uuid.pipe.spec.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { createMock } from '@golevelup/ts-jest';
|
||||
import { ArgumentMetadata, BadRequestException } from '@nestjs/common';
|
||||
import { CustomParseUUIDPipe } from './custom-parse-uuid.pipe';
|
||||
|
||||
describe('CustomParseUUIDPipe', () => {
|
||||
const mockValidUUID = 'FDB4433E-F36B-1410-8588-00E49DB4DA7C';
|
||||
|
||||
it('should parse valid UUID properly', async () => {
|
||||
const result = await CustomParseUUIDPipe.transform(mockValidUUID, createMock<ArgumentMetadata>());
|
||||
|
||||
expect(result).toEqual(mockValidUUID);
|
||||
});
|
||||
|
||||
it('should reject invalid UUID properly', async () => {
|
||||
const act = () => CustomParseUUIDPipe.transform('qwe', createMock<ArgumentMetadata>());
|
||||
|
||||
await expect(act).rejects.toThrow(new BadRequestException('INVALID_UUID'));
|
||||
});
|
||||
});
|
5
src/core/pipes/custom-parse-uuid.pipe.ts
Normal file
5
src/core/pipes/custom-parse-uuid.pipe.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { BadRequestException, ParseUUIDPipe } from '@nestjs/common';
|
||||
|
||||
export const CustomParseUUIDPipe = new ParseUUIDPipe({
|
||||
exceptionFactory: () => new BadRequestException('INVALID_UUID'),
|
||||
});
|
2
src/core/pipes/index.ts
Normal file
2
src/core/pipes/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './custom-parse-uuid.pipe';
|
||||
export * from './validation.pipe';
|
43
src/core/pipes/validation.pipe.spec.ts
Normal file
43
src/core/pipes/validation.pipe.spec.ts
Normal file
@ -0,0 +1,43 @@
|
||||
const ValidationPipeMock = jest.fn();
|
||||
jest.mock('@nestjs/common/pipes/validation.pipe', () => ({
|
||||
ValidationPipe: ValidationPipeMock,
|
||||
}));
|
||||
|
||||
import { DeepMocked, createMock } from '@golevelup/ts-jest';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { i18nValidationErrorFactory } from 'nestjs-i18n';
|
||||
import { Environment } from '~/core/enums';
|
||||
import { buildValidationPipe } from './validation.pipe';
|
||||
|
||||
describe('ValidationPipe', () => {
|
||||
let configMock: DeepMocked<ConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
configMock = createMock<ConfigService>({
|
||||
getOrThrow: jest.fn().mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case 'NODE_ENV':
|
||||
return Environment.DEV;
|
||||
default:
|
||||
throw new Error(`Unexpected key: ${key}`);
|
||||
}
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should build an instance of ValidationPipe configured properly', () => {
|
||||
const pipe = buildValidationPipe(configMock);
|
||||
|
||||
expect(pipe).toBeInstanceOf(ValidationPipeMock);
|
||||
expect(ValidationPipeMock).toBeCalledWith({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
validateCustomDecorators: true,
|
||||
stopAtFirstError: true,
|
||||
forbidNonWhitelisted: false,
|
||||
dismissDefaultMessages: true,
|
||||
enableDebugMessages: true,
|
||||
exceptionFactory: i18nValidationErrorFactory,
|
||||
});
|
||||
});
|
||||
});
|
17
src/core/pipes/validation.pipe.ts
Normal file
17
src/core/pipes/validation.pipe.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { i18nValidationErrorFactory } from 'nestjs-i18n';
|
||||
import { Environment } from '~/core/enums';
|
||||
|
||||
export function buildValidationPipe(config: ConfigService): ValidationPipe {
|
||||
return new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
validateCustomDecorators: true,
|
||||
stopAtFirstError: true,
|
||||
forbidNonWhitelisted: false,
|
||||
dismissDefaultMessages: true,
|
||||
enableDebugMessages: config.getOrThrow('NODE_ENV') === Environment.DEV,
|
||||
exceptionFactory: i18nValidationErrorFactory,
|
||||
});
|
||||
}
|
122
src/core/utils/class-validator-formatter.util.spec.ts
Normal file
122
src/core/utils/class-validator-formatter.util.spec.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { ValidationError } from '@nestjs/common';
|
||||
import { IFieldError } from '../interfaces';
|
||||
import { formatClassValidatorErrors } from './class-validator-formatter.util';
|
||||
|
||||
describe('formatClassValidatorErrors', () => {
|
||||
test('should return empty array when given empty array', () => {
|
||||
expect(formatClassValidatorErrors([])).toEqual([]);
|
||||
});
|
||||
|
||||
test('should format single error object correctly', () => {
|
||||
const errors: ValidationError = {
|
||||
property: 'username',
|
||||
constraints: {
|
||||
isNotEmpty: 'Username should not be empty',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedOutput: IFieldError[] = [
|
||||
{
|
||||
field: 'username',
|
||||
message: 'Username should not be empty',
|
||||
},
|
||||
];
|
||||
|
||||
expect(formatClassValidatorErrors(errors)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('should format multiple error objects correctly', () => {
|
||||
const errors: ValidationError[] = [
|
||||
{
|
||||
property: 'username',
|
||||
constraints: {
|
||||
isNotEmpty: 'Username should not be empty',
|
||||
},
|
||||
// no children list
|
||||
},
|
||||
{
|
||||
property: 'password',
|
||||
constraints: {
|
||||
isNotEmpty: 'Password should not be empty',
|
||||
},
|
||||
children: [], // empty children list
|
||||
},
|
||||
];
|
||||
|
||||
const expectedOutput: IFieldError[] = [
|
||||
{
|
||||
field: 'username',
|
||||
message: 'Username should not be empty',
|
||||
},
|
||||
{
|
||||
field: 'password',
|
||||
message: 'Password should not be empty',
|
||||
},
|
||||
];
|
||||
|
||||
expect(formatClassValidatorErrors(errors)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('should format nested error objects correctly', () => {
|
||||
const errors: ValidationError[] = [
|
||||
{
|
||||
property: 'username',
|
||||
constraints: {
|
||||
isNotEmpty: 'Username should not be empty',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'address',
|
||||
constraints: {},
|
||||
children: [
|
||||
{
|
||||
property: 'city',
|
||||
constraints: {
|
||||
isNotEmpty: 'City should not be empty',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'street',
|
||||
constraints: {
|
||||
isNotEmpty: 'Street should not be empty',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedOutput: IFieldError[] = [
|
||||
{
|
||||
field: 'username',
|
||||
message: 'Username should not be empty',
|
||||
},
|
||||
{
|
||||
field: 'address.city',
|
||||
message: 'City should not be empty',
|
||||
},
|
||||
{
|
||||
field: 'address.street',
|
||||
message: 'Street should not be empty',
|
||||
},
|
||||
];
|
||||
|
||||
expect(formatClassValidatorErrors(errors)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('should format error object without constraints', () => {
|
||||
const errors: ValidationError[] = [
|
||||
{
|
||||
property: 'username',
|
||||
},
|
||||
];
|
||||
|
||||
const expectedOutput: IFieldError[] = [
|
||||
{
|
||||
field: 'username',
|
||||
message: '',
|
||||
},
|
||||
];
|
||||
|
||||
expect(formatClassValidatorErrors(errors)).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
31
src/core/utils/class-validator-formatter.util.ts
Normal file
31
src/core/utils/class-validator-formatter.util.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { ValidationError } from '@nestjs/common';
|
||||
import { IFieldError } from '../interfaces';
|
||||
|
||||
export function formatClassValidatorErrors(errors: ValidationError[] | ValidationError): IFieldError[] {
|
||||
if (!Array.isArray(errors)) {
|
||||
errors = [errors];
|
||||
}
|
||||
|
||||
return errors.map((err: ValidationError) => format(err, '')).flat();
|
||||
}
|
||||
|
||||
function format(error: ValidationError, parentProperty: string): IFieldError[] {
|
||||
const property = propertyPath(parentProperty, error.property);
|
||||
const constraints = Object.values(error.constraints || ['']);
|
||||
|
||||
const formattedErrors: IFieldError[] = constraints.map((constraintMessage) => ({
|
||||
field: property,
|
||||
message: constraintMessage,
|
||||
}));
|
||||
|
||||
if (error.children?.length) {
|
||||
const childErrors = error.children.map((err) => format(err, property)).flat();
|
||||
formattedErrors.push(...childErrors);
|
||||
}
|
||||
|
||||
return formattedErrors;
|
||||
}
|
||||
|
||||
function propertyPath(parentProperty: string, currentProperty: string) {
|
||||
return parentProperty ? `${parentProperty}.${currentProperty}` : currentProperty;
|
||||
}
|
108
src/core/utils/i18n-context-wrapper.util.spec.ts
Normal file
108
src/core/utils/i18n-context-wrapper.util.spec.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { createMock } from '@golevelup/ts-jest';
|
||||
import { I18nContext } from 'nestjs-i18n';
|
||||
import { I18nContextWrapper } from './i18n-context-wrapper.util';
|
||||
|
||||
describe('I18nContextWrapper', () => {
|
||||
let i18nCtxWrapper: I18nContextWrapper;
|
||||
let tMock: jest.Mock;
|
||||
const localizedMsgMock = 'Localized test message';
|
||||
|
||||
beforeEach(() => {
|
||||
tMock = jest.fn();
|
||||
const i18nCtxMock = createMock<I18nContext>({ t: tMock });
|
||||
i18nCtxWrapper = new I18nContextWrapper(i18nCtxMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('t', () => {
|
||||
it('should return the key if i18nCtx is not defined', () => {
|
||||
i18nCtxWrapper = new I18nContextWrapper();
|
||||
|
||||
const result = i18nCtxWrapper.t('test');
|
||||
|
||||
expect(result).toBe('test');
|
||||
expect(tMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the key if key is not string', () => {
|
||||
i18nCtxWrapper = new I18nContextWrapper();
|
||||
|
||||
const result = i18nCtxWrapper.t(null as any);
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(tMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should try to find the localized message in the app namespace if available', () => {
|
||||
tMock.mockReturnValueOnce(localizedMsgMock);
|
||||
|
||||
const result = i18nCtxWrapper.t('test');
|
||||
|
||||
expect(result).toBe(localizedMsgMock);
|
||||
expect(tMock).toHaveBeenNthCalledWith(1, 'app.test');
|
||||
});
|
||||
|
||||
it('should try to find the localized message in the general namespace no available in app', () => {
|
||||
tMock.mockReturnValueOnce('app.test');
|
||||
tMock.mockReturnValueOnce(localizedMsgMock);
|
||||
|
||||
const result = i18nCtxWrapper.t('test');
|
||||
|
||||
expect(result).toBe(localizedMsgMock);
|
||||
expect(tMock).toHaveBeenNthCalledWith(1, 'app.test');
|
||||
expect(tMock).toHaveBeenNthCalledWith(2, 'general.test');
|
||||
});
|
||||
|
||||
it('should return the key if no localized value is found in any namespace', () => {
|
||||
tMock.mockReturnValueOnce('app.test');
|
||||
tMock.mockReturnValueOnce('general.test');
|
||||
tMock.mockReturnValueOnce('test');
|
||||
|
||||
const result = i18nCtxWrapper.t('test');
|
||||
|
||||
expect(result).toBe('test');
|
||||
expect(tMock).toHaveBeenNthCalledWith(1, 'app.test');
|
||||
expect(tMock).toHaveBeenNthCalledWith(2, 'general.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ts', () => {
|
||||
it('"KEY_1" = "msg1", "KEY_2" has no localization, the return value is "msg1"', () => {
|
||||
tMock.mockReturnValueOnce('localized_key_1');
|
||||
|
||||
const result = i18nCtxWrapper.ts('key_1', 'key_2');
|
||||
|
||||
expect(result).toBe('localized_key_1');
|
||||
});
|
||||
|
||||
it('"KEY_1" = "msg1", "KEY_2" = "msg2", the return value is "msg1"', () => {
|
||||
tMock.mockReturnValueOnce('localized_key_1');
|
||||
tMock.mockReturnValueOnce('localized_key_2');
|
||||
|
||||
const result = i18nCtxWrapper.ts('key_1', 'key_2');
|
||||
|
||||
expect(result).toBe('localized_key_1');
|
||||
});
|
||||
|
||||
it('"KEY_2" = "msg2", "KEY_1" has no localization, the return value is "msg2"', () => {
|
||||
tMock.mockReturnValueOnce('key_1');
|
||||
tMock.mockReturnValueOnce('localized_key_2');
|
||||
|
||||
const result = i18nCtxWrapper.ts('key_1', 'key_2');
|
||||
|
||||
expect(result).toBe('localized_key_2');
|
||||
});
|
||||
|
||||
it('"KEY_1" and "KEY_2" have no localization, the return value is "KEY_2"', () => {
|
||||
tMock.mockReturnValueOnce('key_1');
|
||||
tMock.mockReturnValueOnce('key_2');
|
||||
|
||||
const result = i18nCtxWrapper.ts('key_1', 'key_2');
|
||||
|
||||
expect(result).toBe('key_2');
|
||||
});
|
||||
});
|
||||
});
|
55
src/core/utils/i18n-context-wrapper.util.ts
Normal file
55
src/core/utils/i18n-context-wrapper.util.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { I18nContext } from 'nestjs-i18n';
|
||||
|
||||
/**
|
||||
* Wrap the `i18n.t` to
|
||||
* - Avoid errors when the i18n context is not available (e.g. in JSON parsing errors).
|
||||
* - Try to find the localized value in the `general` namespace if available.
|
||||
* - Return the key if no localized value is found in any namespace as a fallback.
|
||||
*/
|
||||
export class I18nContextWrapper {
|
||||
constructor(private readonly i18nCtx?: I18nContext) {}
|
||||
|
||||
t(key: string): string {
|
||||
if (!this.i18nCtx || typeof key !== 'string') {
|
||||
return key;
|
||||
}
|
||||
|
||||
// Trying to find the localized value in the supported namespaces
|
||||
const namespaces = ['app', 'general'];
|
||||
for (const namespace of namespaces) {
|
||||
const namespacedKey = `${namespace}.${key}`;
|
||||
const localizedMsg: string = this.i18nCtx.t(namespacedKey);
|
||||
|
||||
if (namespacedKey !== localizedMsg) {
|
||||
return localizedMsg;
|
||||
}
|
||||
}
|
||||
|
||||
// If the key has a localized value in the given namespace
|
||||
// Or no localized value found, return the same key as a fallback
|
||||
return this.i18nCtx.t(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first message that can found from the passed list of keys.
|
||||
* Think of it as or `||` operation for localization, `i18n(key1) || i18n(key2)`, but the value is truthy if it has a
|
||||
* localization.
|
||||
*
|
||||
* `ts('KEY_1', 'KEY_2')`:
|
||||
* - `'KEY_1' = 'msg1', 'KEY_2' has no localization`, the return value is `msg1`.
|
||||
* - `'KEY_1' = 'msg1', 'KEY_2' = 'msg2'`, the return value is `msg1`.
|
||||
* - `'KEY_2' = 'msg2', 'KEY_1' has no localization`, the return value is `msg2`.
|
||||
* - `'KEY_1' and 'KEY_2' have no localization`, the return value is `KEY_2`.
|
||||
*/
|
||||
ts(...keys: string[]): string {
|
||||
let last = '';
|
||||
|
||||
for (const key of keys) {
|
||||
const localizedMsg = this.t(key);
|
||||
if (localizedMsg !== key) return localizedMsg; // found a localization, return early
|
||||
last = localizedMsg;
|
||||
}
|
||||
|
||||
return last;
|
||||
}
|
||||
}
|
3
src/core/utils/index.ts
Normal file
3
src/core/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './response.factory.util';
|
||||
export * from './i18n-context-wrapper.util';
|
||||
export * from './class-validator-formatter.util';
|
76
src/core/utils/response.factory.util.spec.ts
Normal file
76
src/core/utils/response.factory.util.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { ErrorCategory } from '../enums';
|
||||
import { IFieldError, IResponseError } from '../interfaces';
|
||||
import { ResponseFactory } from './response.factory.util';
|
||||
|
||||
describe('ResponseFactory', () => {
|
||||
describe('data', () => {
|
||||
const data = { name: 'abc' };
|
||||
|
||||
const response = ResponseFactory.data(data);
|
||||
|
||||
expect(response).toEqual({
|
||||
data,
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataArray', () => {
|
||||
const data = { name: 'abc' };
|
||||
|
||||
const response = ResponseFactory.dataArray([data]);
|
||||
|
||||
expect(response).toEqual({
|
||||
data: [data],
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataPage', () => {
|
||||
const data = { name: 'abc' };
|
||||
const meta = { page: 1, size: 10, itemCount: 15 };
|
||||
|
||||
const response = ResponseFactory.dataPage([data], meta);
|
||||
|
||||
expect(response).toEqual({
|
||||
data: [data],
|
||||
meta: {
|
||||
page: 1,
|
||||
size: 10,
|
||||
itemCount: 15,
|
||||
pageCount: 2,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('error', () => {
|
||||
it('should return an IResponseError object with the correct properties', () => {
|
||||
const category: ErrorCategory = ErrorCategory.INTERNAL_SERVER_ERROR;
|
||||
const message = 'Server error';
|
||||
const errors: IFieldError[] = [
|
||||
{ field: 'email', message: 'Email is invalid' },
|
||||
{ field: 'password', message: 'Password is too short' },
|
||||
];
|
||||
|
||||
const response: IResponseError = ResponseFactory.error(category, message, errors);
|
||||
|
||||
expect(response).toMatchObject({
|
||||
category,
|
||||
message,
|
||||
errors,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an IResponseError object with an empty errors array if errors is not provided', () => {
|
||||
const category: ErrorCategory = ErrorCategory.VALIDATION_ERROR;
|
||||
const message = 'Validation error';
|
||||
|
||||
const response: IResponseError = ResponseFactory.error(category, message);
|
||||
|
||||
expect(response).toMatchObject({
|
||||
category,
|
||||
message,
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
25
src/core/utils/response.factory.util.ts
Normal file
25
src/core/utils/response.factory.util.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { DataArrayResponseDto, DataPageResponseDto, DataResponseDto, IPageMeta, PageMetaResponseDto } from '../dtos';
|
||||
import { ErrorCategory } from '../enums';
|
||||
import { IFieldError, IResponseError } from '../interfaces';
|
||||
|
||||
export class ResponseFactory {
|
||||
static data<T>(data: T): DataResponseDto<T> {
|
||||
return new DataResponseDto(data);
|
||||
}
|
||||
|
||||
static dataArray<T>(data: T[]): DataArrayResponseDto<T> {
|
||||
return new DataArrayResponseDto(data);
|
||||
}
|
||||
|
||||
static dataPage<T>(data: T[], meta: IPageMeta): DataPageResponseDto<T> {
|
||||
return new DataPageResponseDto(data, new PageMetaResponseDto(meta));
|
||||
}
|
||||
|
||||
static error(category: ErrorCategory, message: string, errors?: IFieldError[]): IResponseError {
|
||||
return {
|
||||
category,
|
||||
message,
|
||||
errors: errors || [],
|
||||
};
|
||||
}
|
||||
}
|
1
src/db/index.ts
Normal file
1
src/db/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * as migrations from './migrations';
|
20
src/db/migrations/1731310840593-create-template-table.ts
Normal file
20
src/db/migrations/1731310840593-create-template-table.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateTemplateTable1731310840593 implements MigrationInterface {
|
||||
name = 'CreateTemplateTable1731310840593';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "template" (
|
||||
"id" SERIAL,
|
||||
"name" character varying(255) NOT NULL,
|
||||
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_ac51aa5181ee2036f5ca482857d" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "template"`);
|
||||
}
|
||||
}
|
1
src/db/migrations/index.ts
Normal file
1
src/db/migrations/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './1731310840593-create-template-table';
|
0
src/dtos/request/index.ts
Normal file
0
src/dtos/request/index.ts
Normal file
82
src/health/health.controller.spec.ts
Normal file
82
src/health/health.controller.spec.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { DeepMocked, createMock } from '@golevelup/ts-jest';
|
||||
import { HealthCheckResult, HealthCheckService, HttpHealthIndicator, TypeOrmHealthIndicator } from '@nestjs/terminus';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
describe('HealthController', () => {
|
||||
let healthController: HealthController;
|
||||
let healthCheckServiceMock: DeepMocked<HealthCheckService>;
|
||||
let databaseHealthIndicatorMock: DeepMocked<TypeOrmHealthIndicator>;
|
||||
let httpHealthIndicatorMock: DeepMocked<HttpHealthIndicator>;
|
||||
|
||||
const mockHealthCheckResult: HealthCheckResult = { status: 'ok', details: {} };
|
||||
|
||||
beforeEach(async () => {
|
||||
// Mocks
|
||||
|
||||
healthCheckServiceMock = createMock<HealthCheckService>({ check: jest.fn() });
|
||||
databaseHealthIndicatorMock = createMock<TypeOrmHealthIndicator>();
|
||||
httpHealthIndicatorMock = createMock<HttpHealthIndicator>();
|
||||
|
||||
// Module
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{ provide: HealthCheckService, useValue: healthCheckServiceMock },
|
||||
{ provide: TypeOrmHealthIndicator, useValue: databaseHealthIndicatorMock },
|
||||
{ provide: HttpHealthIndicator, useValue: httpHealthIndicatorMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
healthController = module.get<HealthController>(HealthController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(healthController).toBeDefined();
|
||||
});
|
||||
|
||||
describe('With no detailed dependencies checks', () => {
|
||||
it('should return a health check result with no details', async () => {
|
||||
healthCheckServiceMock.check.mockResolvedValueOnce(mockHealthCheckResult);
|
||||
|
||||
const result = await healthController.checkHealth();
|
||||
|
||||
expect(result).toEqual(mockHealthCheckResult);
|
||||
expect(healthCheckServiceMock.check).toHaveBeenCalledTimes(1);
|
||||
expect(healthCheckServiceMock.check).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('With detailed dependencies checks', () => {
|
||||
it('should return a health check result with dependencies details', async () => {
|
||||
const expectedIndicatorsCnt = 1;
|
||||
healthCheckServiceMock.check.mockResolvedValueOnce(mockHealthCheckResult);
|
||||
|
||||
const result = await healthController.checkHealthDetails();
|
||||
|
||||
expect(result).toEqual(mockHealthCheckResult);
|
||||
expect(healthCheckServiceMock.check).toHaveBeenCalledTimes(1);
|
||||
expect(healthCheckServiceMock.check).toHaveBeenCalledWith(
|
||||
Array(expectedIndicatorsCnt).fill(expect.any(Function)),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the expected health indicators to get the health result', async () => {
|
||||
const expectedIndicatorsCnt = 1;
|
||||
healthCheckServiceMock.check.mockImplementationOnce((indicators) => indicators as any);
|
||||
|
||||
const indicators: any = await healthController.checkHealthDetails();
|
||||
expect(healthCheckServiceMock.check).toHaveBeenCalledWith(
|
||||
Array(expectedIndicatorsCnt).fill(expect.any(Function)),
|
||||
);
|
||||
|
||||
// call each indicator to check the arguments passed to it
|
||||
for (let i = 0; i < expectedIndicatorsCnt; i++) {
|
||||
indicators[`${i}`]();
|
||||
}
|
||||
// database health indicator(s)
|
||||
expect(databaseHealthIndicatorMock.pingCheck).toHaveBeenCalledTimes(1);
|
||||
expect(databaseHealthIndicatorMock.pingCheck).toHaveBeenCalledWith('database');
|
||||
});
|
||||
});
|
||||
});
|
32
src/health/health.controller.ts
Normal file
32
src/health/health.controller.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { HealthCheck, HealthCheckService, HttpHealthIndicator, TypeOrmHealthIndicator } from '@nestjs/terminus';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
@Controller('health')
|
||||
@ApiTags('Health')
|
||||
@SkipThrottle()
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly healthCheckService: HealthCheckService,
|
||||
private readonly databaseHealthIndicator: TypeOrmHealthIndicator,
|
||||
private readonly httpHealthIndicator: HttpHealthIndicator,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
checkHealth() {
|
||||
return this.healthCheckService.check([]);
|
||||
}
|
||||
|
||||
@Get('details')
|
||||
@HealthCheck()
|
||||
checkHealthDetails() {
|
||||
const healthIndicators = [
|
||||
() => this.databaseHealthIndicator.pingCheck('database'),
|
||||
// add your own health indicators here
|
||||
];
|
||||
|
||||
return this.healthCheckService.check(healthIndicators);
|
||||
}
|
||||
}
|
23
src/health/health.module.spec.ts
Normal file
23
src/health/health.module.spec.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthModule } from './health.module';
|
||||
|
||||
describe('HealthModule', () => {
|
||||
let module: TestingModule;
|
||||
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true }), HealthModule],
|
||||
}).compile();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
it('should compile the module', () => {
|
||||
expect(module).toBeDefined();
|
||||
expect(module.get(HealthController)).toBeInstanceOf(HealthController);
|
||||
});
|
||||
});
|
15
src/health/health.module.ts
Normal file
15
src/health/health.module.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TerminusModule.forRoot({
|
||||
logger: Logger,
|
||||
}),
|
||||
HttpModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
5
src/i18n/ar/app.json
Normal file
5
src/i18n/ar/app.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"CUSTOM": {
|
||||
"TEMPLATE_ERROR": "خطأ في القالب"
|
||||
}
|
||||
}
|
15
src/i18n/ar/general.json
Normal file
15
src/i18n/ar/general.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"PROPERTY_MAPPINGS": {
|
||||
"paginationPage": "رقم الصفحة",
|
||||
"paginationSize": "حجم الصفحة",
|
||||
"test": {
|
||||
"name": "الاسم"
|
||||
}
|
||||
},
|
||||
"UNAUTHORIZED_ERROR": "يجب تسجيل الدخول مره اخرى",
|
||||
"FORBIDDEN_ERROR": "ليس لديك الصلاحيات الكافية لتنفيذ هذا الطلب",
|
||||
"NOT_FOUND_ERROR": "غير موجود",
|
||||
"INTERNAL_SERVER_ERROR": "يرجى التواصل بمدير العلاقات للمساعدة",
|
||||
"TOO_MANY_REQUESTS": "تم تجاوز الحد المسموح به من المحاولات في وقت قصير",
|
||||
"INVALID_UUID": "صيغة الرقم التعريفي خاطئة"
|
||||
}
|
26
src/i18n/ar/validation.json
Normal file
26
src/i18n/ar/validation.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"IsNotEmpty": "$t({path}.PROPERTY_MAPPINGS.{property}) مطلوب",
|
||||
"IsString": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون نصا",
|
||||
"IsNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون رقما",
|
||||
"IsInt": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون رقما صحيحا",
|
||||
"IsEnum": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون إحدي القيم المقبولة",
|
||||
"IsNumberString": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون رقما في هيئة نص",
|
||||
"Min": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن لا يقل عن {constraints.0}",
|
||||
"Max": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن لا يزيد عن {constraints.0}",
|
||||
"MinLength": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن لا يقل عن {constraints.0} حروف/حرفا",
|
||||
"MaxLength": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن لا يزيد عن {constraints.0} حروف/حرفا",
|
||||
"IsDateString": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون تاريخ ميلادي صحيح",
|
||||
"IsSameAfterDate": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون تاريخ الانتهاء بعد تاريخ البدء",
|
||||
"IsBeforeDateToday": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان لا يتجاوز تاريخ اليوم",
|
||||
"IsLength": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يتكون من {constraints.0} حروف/حرفا",
|
||||
"IsBIC": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون كودا صحيحا",
|
||||
"ConditionalLength": "$t({path}.PROPERTY_MAPPINGS.{property}) عدد الاحرف يجب ان يكون بين {constraints.0.minLength} و {constraints.0.maxLength}",
|
||||
"IsBoolean": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون شرط منطقي",
|
||||
"IsUUID": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون بصيغة سليمة",
|
||||
"IsPhoneNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون رقما صحيحا",
|
||||
"Length": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون بين {constraints.0} - {constraints.1} حروف/ارقام",
|
||||
"IsLengthIn": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون {constraints.0} حروف/ارقام",
|
||||
"Matches": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يتطابق مع النمط",
|
||||
"IsValidExpiryDate": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون تاريخ انتهاء بطاقه صحيح",
|
||||
"IsEnglishOnly": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون باللغة الانجليزية"
|
||||
}
|
5
src/i18n/en/app.json
Normal file
5
src/i18n/en/app.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"CUSTOM": {
|
||||
"TEMPLATE_ERROR": "Template error"
|
||||
}
|
||||
}
|
16
src/i18n/en/general.json
Normal file
16
src/i18n/en/general.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"PROPERTY_MAPPINGS": {
|
||||
"paginationPage": "page",
|
||||
"paginationSize": "page size",
|
||||
"test": {
|
||||
"name": "name",
|
||||
"age": "age"
|
||||
}
|
||||
},
|
||||
"UNAUTHORIZED_ERROR": "You have to login again",
|
||||
"FORBIDDEN_ERROR": "You don't have the required permissions to perform this action",
|
||||
"NOT_FOUND_ERROR": "Not found",
|
||||
"INTERNAL_SERVER_ERROR": "Please contact your relationship manager for further assistance",
|
||||
"TOO_MANY_REQUESTS": "Too many requests in short period",
|
||||
"INVALID_UUID": "Invalid UUID format"
|
||||
}
|
26
src/i18n/en/validation.json
Normal file
26
src/i18n/en/validation.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"IsNotEmpty": "$t({path}.PROPERTY_MAPPINGS.{property}) cannot be empty",
|
||||
"IsString": "$t({path}.PROPERTY_MAPPINGS.{property}) value must be a string",
|
||||
"IsNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a number",
|
||||
"IsInt": "$t({path}.PROPERTY_MAPPINGS.{property}) must be an integer",
|
||||
"IsEnum": "$t({path}.PROPERTY_MAPPINGS.{property}) must be one of the accepted values",
|
||||
"IsNumberString": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a number in string format",
|
||||
"Min": "$t({path}.PROPERTY_MAPPINGS.{property}) must not be less than {constraints.0}",
|
||||
"Max": "$t({path}.PROPERTY_MAPPINGS.{property}) must not be greater than {constraints.0}",
|
||||
"MinLength": "$t({path}.PROPERTY_MAPPINGS.{property}) must not be shorter than {constraints.0} character(s)",
|
||||
"MaxLength": "$t({path}.PROPERTY_MAPPINGS.{property}) must not be longer than {constraints.0} character(s)",
|
||||
"IsDateString": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid date string",
|
||||
"IsSameAfterDate": "$t({path}.PROPERTY_MAPPINGS.{property}) toDate should be same or after fromDate.",
|
||||
"IsBeforeDateToday": "$t({path}.PROPERTY_MAPPINGS.{property}) should be before today",
|
||||
"IsLength": "$t({path}.PROPERTY_MAPPINGS.{property}) must contain {constraints.0} character(s)",
|
||||
"IsBIC": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid code",
|
||||
"ConditionalLength": "$t({path}.PROPERTY_MAPPINGS.{property}) number of carters should be between {constraints.0.minLength} and {constraints.0.maxLength}",
|
||||
"IsBoolean": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a boolean value",
|
||||
"IsUUID": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid UUID",
|
||||
"IsPhoneNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid mobile number",
|
||||
"Length": "$t({path}.PROPERTY_MAPPINGS.{property}) must be between {constraints.0} - {constraints.1} characters/digits",
|
||||
"IsLengthIn": "$t({path}.PROPERTY_MAPPINGS.{property}) must be {constraints.0} characters/digits",
|
||||
"Matches": "$t({path}.PROPERTY_MAPPINGS.{property}) invalid format",
|
||||
"IsValidExpiryDate": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid expiry date",
|
||||
"IsEnglishOnly": "$t({path}.PROPERTY_MAPPINGS.{property}) must be in English"
|
||||
}
|
37
src/main.ts
Normal file
37
src/main.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { AppModule } from './app.module';
|
||||
const DEFAULT_PORT = 3000;
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.useLogger(app.get(Logger));
|
||||
const config = app.get(ConfigService);
|
||||
const swaggerDocument = await createSwagger(app);
|
||||
|
||||
if (config.getOrThrow<string>('NODE_ENV') === 'dev') {
|
||||
SwaggerModule.setup(config.getOrThrow<string>('SWAGGER_API_DOCS_PATH'), app, swaggerDocument, {
|
||||
swaggerOptions: {
|
||||
tagsSorter: 'alpha',
|
||||
docExpansion: 'none',
|
||||
},
|
||||
});
|
||||
}
|
||||
await app.listen(process.env.PORT ?? DEFAULT_PORT);
|
||||
}
|
||||
|
||||
function createSwagger(app: INestApplication) {
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Zod APIs')
|
||||
.setDescription('API documentation for Zod app')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth({ in: 'header', type: 'http' })
|
||||
.build();
|
||||
|
||||
return SwaggerModule.createDocument(app, swaggerConfig);
|
||||
}
|
||||
|
||||
bootstrap();
|
21
test/app.e2e-spec.ts
Normal file
21
test/app.e2e-spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!');
|
||||
});
|
||||
});
|
10
test/global-setup.ts
Normal file
10
test/global-setup.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import 'tsconfig-paths/register';
|
||||
import { Environment } from '~/core/enums';
|
||||
|
||||
export default function globalSetup() {
|
||||
process.env = {
|
||||
...process.env,
|
||||
NODE_ENV: Environment.TEST,
|
||||
// override env vars
|
||||
};
|
||||
}
|
9
test/jest-e2e.json
Normal file
9
test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"esModuleInterop": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"~": ["./src"],
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"]
|
||||
}
|
20
typeorm.cli.ts
Normal file
20
typeorm.cli.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './src/app.module';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Getting data source through NestJS app helps in getting entities dynamically with "autoLoadEntities" NestJS feature
|
||||
* as well as keeping migrations config in sync with what is configured in the app.
|
||||
*/
|
||||
async function getTypeOrmDataSource() {
|
||||
process.env.MIGRATIONS_RUN = 'false';
|
||||
|
||||
const app = await NestFactory.createApplicationContext(AppModule);
|
||||
|
||||
const dataSource = app.get(DataSource);
|
||||
await app.close();
|
||||
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
export default getTypeOrmDataSource();
|
Reference in New Issue
Block a user