├── .env.sample ├── .gitattributes ├── .gitignore ├── .ncurc ├── .prettierrc ├── LICENSE ├── README.md ├── bin ├── NamingStrategy.js ├── entity.ts └── ormconfig.ts ├── eslint.config.js ├── jest.config.ts ├── nest-cli.json ├── package-lock.json ├── package.json ├── public └── index.html ├── src ├── app.middleware.ts ├── app.module.ts ├── app.ts ├── auth │ ├── auth.interface.ts │ ├── auth.module.ts │ ├── auth.serializer.ts │ ├── auth.service.ts │ ├── guards │ │ ├── authenticated.guard.ts │ │ ├── index.ts │ │ ├── jwt-auth.guard.ts │ │ ├── jwt-verify.guard.ts │ │ ├── local-auth.guard.ts │ │ └── local-login.guard.ts │ ├── index.ts │ └── strategies │ │ ├── index.ts │ │ ├── jwt-verify.strategy.ts │ │ ├── jwt.strategy.ts │ │ └── local.strategy.ts ├── base │ ├── base.module.ts │ ├── controllers │ │ ├── auth.controller.ts │ │ ├── health.controller.ts │ │ └── index.ts │ └── index.ts ├── common │ ├── common.module.ts │ ├── decorators │ │ ├── index.ts │ │ ├── public.decorator.ts │ │ ├── req-user.decorator.ts │ │ └── roles.decorator.ts │ ├── filters │ │ ├── exceptions.filter.ts │ │ └── index.ts │ ├── guards │ │ ├── index.ts │ │ └── roles.guard.ts │ ├── index.ts │ ├── middleware │ │ ├── index.ts │ │ └── logger-context.middleware.ts │ └── providers │ │ ├── config.service.ts │ │ ├── index.ts │ │ └── util.service.ts ├── config │ ├── README.md │ ├── config.interface.ts │ ├── configuration.ts │ ├── envs │ │ ├── default.ts │ │ ├── development.ts │ │ ├── production.ts │ │ └── test.ts │ ├── index.ts │ └── logger.config.ts ├── debug │ ├── README.md │ ├── debug-log.decorator.ts │ ├── debug.constant.ts │ ├── debug.decorator.ts │ ├── debug.explorer.ts │ ├── debug.interface.ts │ ├── debug.module-definition.ts │ ├── debug.module.ts │ ├── index.ts │ └── sample │ │ ├── index.ts │ │ ├── log-controller.decorator.ts │ │ ├── sample.controller.ts │ │ ├── sample.module.ts │ │ ├── sample.service.ts │ │ └── simple-log.controller.ts ├── entity │ ├── sampledb1 │ │ ├── index.ts │ │ └── sampletable1.entity.ts │ └── sampledb2 │ │ ├── index.ts │ │ └── sampletable2.entity.ts ├── gql │ ├── dto │ │ ├── index.ts │ │ ├── simple.args.ts │ │ └── simple.input.ts │ ├── gql.module.ts │ ├── index.ts │ ├── models │ │ ├── index.ts │ │ ├── payload.model.ts │ │ ├── simple.model.ts │ │ └── user.model.ts │ ├── providers │ │ ├── index.ts │ │ └── simple.service.ts │ ├── resolvers │ │ ├── index.ts │ │ └── simple.resolver.ts │ └── scalars │ │ ├── date.scalar.ts │ │ └── index.ts ├── repl.ts ├── sample │ ├── controllers │ │ ├── crud.controller.spec.ts │ │ ├── crud.controller.ts │ │ ├── index.ts │ │ ├── sample.controller.spec.ts │ │ └── sample.controller.ts │ ├── dto │ │ ├── create.dto.ts │ │ ├── index.ts │ │ ├── sample.dto.ts │ │ └── update.dto.ts │ ├── index.ts │ ├── providers │ │ ├── crud.service.spec.ts │ │ ├── crud.service.ts │ │ ├── database.service.ts │ │ ├── date.service.ts │ │ └── index.ts │ └── sample.module.ts ├── shared │ ├── README.md │ ├── foobar │ │ ├── foobar.module.ts │ │ ├── foobar.service.ts │ │ └── index.ts │ └── user │ │ ├── index.ts │ │ ├── user.interface.ts │ │ ├── user.module.ts │ │ └── user.service.ts └── swagger.ts ├── test ├── e2e │ ├── crud.spec.ts │ ├── gql.spec.ts │ ├── jwt-auth.spec.ts │ └── local-auth.spec.ts ├── jest.e2e.transformer.ts ├── jest.e2e.ts ├── test.spec.ts └── tsconfig.e2e.json ├── tsconfig.build.json ├── tsconfig.json └── typings └── global.d.ts /.env.sample: -------------------------------------------------------------------------------- 1 | DB_TYPE = mysql 2 | DB_HOST = 127.0.0.1 3 | DB_PORT = 3306 4 | DB_USER = username 5 | DB_PASSWORD = password 6 | DB_NAME = dbname 7 | 8 | # https://jwt.io/introduction 9 | JWT_SECRET="secretKey" 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # JS and TS files must always use LF for tools to work 5 | *.js eol=lf 6 | *.ts eol=lf 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | .history 4 | .DS_Store 5 | dist 6 | docs 7 | build 8 | 9 | 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | 18 | # Diagnostic reports (https://nodejs.org/api/report.html) 19 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | *.lcov 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | .grunt 39 | 40 | # Bower dependency directory (https://bower.io/) 41 | bower_components 42 | 43 | # node-waf configuration 44 | .lock-wscript 45 | 46 | # Compiled binary addons (https://nodejs.org/api/addons.html) 47 | build/Release 48 | 49 | # Dependency directories 50 | node_modules/ 51 | jspm_packages/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # next.js build output 79 | .next 80 | 81 | # nuxt.js build output 82 | .nuxt 83 | 84 | # vuepress build output 85 | .vuepress/dist 86 | 87 | # Serverless directories 88 | .serverless/ 89 | 90 | # FuseBox cache 91 | .fusebox/ 92 | 93 | # DynamoDB Local files 94 | .dynamodb/ 95 | -------------------------------------------------------------------------------- /.ncurc: -------------------------------------------------------------------------------- 1 | { 2 | "reject": ["nanoid", "typeorm-model-generator"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "jsxSingleQuote": false, 9 | "trailingComma": "all", 10 | "bracketSpacing": true, 11 | "jsxBracketSameLine": true, 12 | "arrowParens": "always" 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 CatsMiaow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nestjs-project-structure 2 | 3 | Node.js framework NestJS project structure 4 | > Started from this issue: [nestjs/nest#2249](https://github.com/nestjs/nest/issues/2249#issuecomment-494734673) 5 | 6 | ## Alternatives 7 | 8 | This example is based on the modules recommended by the NestJS [official documentation](https://docs.nestjs.com) as default (introduced at the top of the section). \ 9 | If you focus on the performance or features of the module, you can consider: 10 | 11 | - [Fastify](https://docs.nestjs.com/techniques/performance) instead of `Express` 12 | - [MikroORM](https://docs.nestjs.com/recipes/mikroorm) instead of `TypeORM` 13 | - or [DrizzleORM](https://trilon.io/blog/nestjs-drizzleorm-a-great-match) 14 | - or [Sequelize](https://docs.nestjs.com/techniques/database#sequelize-integration) 15 | - or [Prisma](https://docs.nestjs.com/recipes/prisma) 16 | - [SWC](https://docs.nestjs.com/recipes/swc#swc) instead of `TypeScript compiler` 17 | - [Vitest](https://docs.nestjs.com/recipes/swc#vitest) instead of `Jest` 18 | - [ESM](https://nodejs.org/api/esm.html) instead of `CommonJS` 19 | 20 | Check out the [nestjs-project-performance](https://github.com/CatsMiaow/nestjs-project-performance) repository for examples using this alternative. 21 | 22 | ## Configuration 23 | 24 | 1. Create a `.env` file 25 | - Rename the [.env.sample](.env.sample) file to `.env` to fix it. 26 | 2. Edit env config 27 | - Edit the file in the [config](src/config)/envs folder. 28 | - `default`, `development`, `production`, `test` 29 | 30 | ## Installation 31 | 32 | ```sh 33 | # 1. node_modules 34 | npm ci 35 | # 2. When synchronize database from existing entities 36 | npm run entity:sync 37 | # 2-1. When import entities from an existing database 38 | npm run entity:load 39 | ``` 40 | 41 | If you use multiple databases in `entity:load`, [modify them.](bin/entity.ts#L47-L48) 42 | 43 | ## Development 44 | 45 | ```sh 46 | npm run start:dev 47 | # https://docs.nestjs.com/recipes/repl 48 | npm run start:repl 49 | ``` 50 | 51 | Run [http://localhost:3000](http://localhost:3000) 52 | 53 | ## Test 54 | 55 | ```sh 56 | npm test # exclude e2e 57 | npm run test:e2e 58 | ``` 59 | 60 | ## Production 61 | 62 | ```sh 63 | npm run lint 64 | npm run build 65 | # define environment variable yourself. 66 | # NODE_ENV=production PORT=8000 NO_COLOR=true node dist/app 67 | node dist/app 68 | # OR 69 | npm start 70 | ``` 71 | 72 | ## Folders 73 | 74 | ```js 75 | +-- bin // Custom tasks 76 | +-- dist // Source build 77 | +-- public // Static Files 78 | +-- src 79 | | +-- config // Environment Configuration 80 | | +-- entity // TypeORM Entities 81 | | +-- auth // Authentication 82 | | +-- common // Global Nest Module 83 | | | +-- constants // Constant value and Enum 84 | | | +-- controllers // Nest Controllers 85 | | | +-- decorators // Nest Decorators 86 | | | +-- dto // DTO (Data Transfer Object) Schema, Validation 87 | | | +-- filters // Nest Filters 88 | | | +-- guards // Nest Guards 89 | | | +-- interceptors // Nest Interceptors 90 | | | +-- interfaces // TypeScript Interfaces 91 | | | +-- middleware // Nest Middleware 92 | | | +-- pipes // Nest Pipes 93 | | | +-- providers // Nest Providers 94 | | | +-- * // models, repositories, services... 95 | | +-- shared // Shared Nest Modules 96 | | +-- gql // GraphQL Structure 97 | | +-- * // Other Nest Modules, non-global, same as common structure above 98 | +-- test // Jest testing 99 | +-- typings // Modules and global type definitions 100 | 101 | // Module structure 102 | // Add folders according to module scale. If it's small, you don't need to add folders. 103 | +-- src/greeter 104 | | +-- * // folders 105 | | +-- greeter.constant.ts 106 | | +-- greeter.controller.ts 107 | | +-- greeter.service.ts 108 | | +-- greeter.module.ts 109 | | +-- greeter.*.ts 110 | | +-- index.ts 111 | ``` 112 | 113 | This is the most basic structure to start a NestJS project. \ 114 | You should choose the right architecture[[1]](https://romanglushach.medium.com/c0f93b8a1b96) (Layered, Clean, Onion, Hexagonal ...)[[2]](https://gist.github.com/EliFuzz/8ab693db36ff33ead1445a43c3f0ef7e) based on the size of your project. 115 | 116 | ## Implements 117 | 118 | - See [bootstrap](src/app.ts), [app.module](src/app.module.ts) 119 | - Database, Module Router, Static Files, Validation, Pino Logger 120 | - [Global Exception Filter](src/common/filters/exceptions.filter.ts) 121 | - [Global Logging Context Middleware](src/common/middleware/logger-context.middleware.ts) 122 | - [Custom Logger](src/config/logger.config.ts) with nestjs-pino 123 | - [Custom Decorators](src/debug) Example at Nest level 124 | - [Configuration](src/config) 125 | - [Authentication](src/auth) - JWT and Session login with Passport 126 | - [Role-based Guard](src/common/guards/roles.guard.ts) 127 | - Controller Routes 128 | - [Auth Login](src/base/controllers/auth.controller.ts) 129 | - [Sample](src/sample/controllers/sample.controller.ts) Parameter and [DTO](src/sample/dto/sample.dto.ts) 130 | - [CRUD API](src/sample/controllers/crud.controller.ts) Sample 131 | - [Database Query](src/sample/providers/database.service.ts) Example 132 | - [Unit Test](src/sample/providers/crud.service.spec.ts) 133 | - [E2E Test](test/e2e) 134 | - [Shared Modules](src/shared) Example 135 | - [GraphQL Structure](src/gql) Example 136 | 137 | ## Documentation 138 | 139 | ```sh 140 | # APP, Compodoc 141 | npm run doc #> http://localhost:8080 142 | # API, Swagger - src/swagger.ts 143 | npm run doc:api #> http://localhost:8000/api 144 | ``` 145 | 146 | ### File Naming for Class 147 | 148 | ```ts 149 | export class PascalCaseSuffix {} //= pascal-case.suffix.ts 150 | // Except for suffix, PascalCase to hyphen-case 151 | class FooBarNaming {} //= foo-bar.naming.ts 152 | class FooController {} //= foo.controller.ts 153 | class BarQueryDto {} //= bar-query.dto.ts 154 | ``` 155 | 156 | ### Interface Naming 157 | 158 | ```ts 159 | // https://stackoverflow.com/questions/541912 160 | // https://stackoverflow.com/questions/2814805 161 | interface User {} 162 | interface CustomeUser extends User {} 163 | interface ThirdCustomeUser extends CustomeUser {} 164 | ``` 165 | 166 | ### Index Exporting 167 | 168 | ```diff 169 | # It is recommended to place index.ts in each folder and export. 170 | # Unless it's a special case, it is import from a folder instead of directly from a file. 171 | - import { FooController } from './controllers/foo.controller'; 172 | - import { BarController } from './controllers/bar.controller'; 173 | + import { FooController, BarController } from './controllers'; 174 | # My preferred method is to place only one fileOrFolder name at the end of the path. 175 | - import { UtilService } from '../common/providers/util.service'; 176 | + import { UtilService } from '../common'; 177 | ``` 178 | 179 | #### Circular dependency 180 | 181 | 182 | 183 | ```diff 184 | # Do not use a path that ends with a dot. 185 | - import { FooService } from '.'; 186 | - import { BarService } from '..'; 187 | + import { FooService } from './foo.service'; 188 | + import { BarService } from '../providers'; 189 | ``` 190 | 191 | ### Variables Naming 192 | 193 | > refer to [Naming cheatsheet](https://github.com/kettanaito/naming-cheatsheet) 194 | 195 | ### Links 196 | 197 | - [Better Nodejs Project](https://github.com/CatsMiaow/better-nodejs-project) 198 | - [Monorepo with npm Workspaces](https://github.com/CatsMiaow/node-monorepo-workspaces) 199 | - [Nest Project Performance](https://github.com/CatsMiaow/nestjs-project-performance) 200 | - [NestJS](https://docs.nestjs.com) 201 | - [Nest Sample](https://github.com/nestjs/nest/tree/master/sample) 202 | - [Awesome Nest](https://github.com/nestjs/awesome-nestjs) 203 | - [TypeORM](https://typeorm.io) 204 | -------------------------------------------------------------------------------- /bin/NamingStrategy.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const NamingStrategy = require('typeorm-model-generator/dist/src/NamingStrategy'); 3 | 4 | // https://github.com/Kononnable/typeorm-model-generator/issues/171 5 | NamingStrategy.entityName = function (entityName, entity) { 6 | // console.log(entityName, entity.database); 7 | return entityName; 8 | }; 9 | 10 | // https://github.com/Kononnable/typeorm-model-generator/issues/236 11 | NamingStrategy.fileName = function (fileName) { 12 | // https://docs.nestjs.com/openapi/cli-plugin 13 | // Add entity suffix for analysed in swagger plugin 14 | return `${fileName}.entity`; 15 | }; 16 | 17 | module.exports = { 18 | ...NamingStrategy, 19 | }; 20 | -------------------------------------------------------------------------------- /bin/entity.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, @typescript-eslint/triple-slash-reference */ 2 | /// 3 | import { config } from 'dotenv'; 4 | import { spawnSync } from 'node:child_process'; 5 | import { readdirSync, writeFileSync } from 'node:fs'; 6 | import path from 'node:path'; 7 | import prompts from 'prompts'; 8 | import { rimrafSync } from 'rimraf'; 9 | 10 | config(); 11 | if (!process.env.DB_HOST) { 12 | throw new Error('Create a .env file'); 13 | } 14 | 15 | (async (): Promise => { 16 | const response = await prompts([ 17 | { 18 | type: 'text', 19 | name: 'db', 20 | message: 'Please enter a database name.', 21 | validate: (value: string): boolean => !!value, 22 | } /* { 23 | type: 'select', 24 | name: 'db', 25 | message: 'Please select a database name.', 26 | choices: [ 27 | { title: 'db1' }, 28 | { title: 'db2' }, 29 | ], 30 | } */, 31 | ]); 32 | 33 | const { db } = <{ db: string }>response; 34 | const MODEL_DIR = path.join(__dirname, '../src/entity', db); 35 | rimrafSync(`${MODEL_DIR}/*`); 36 | 37 | const generatorConfig = [ 38 | '--noConfig', 39 | '--cf none', // file names 40 | '--ce pascal', // class names 41 | '--cp none', // property names 42 | '--strictMode !', // strictPropertyInitialization 43 | `--namingStrategy ${path.join(__dirname, 'NamingStrategy.js')}`, 44 | `-h ${process.env.DB_HOST}`, 45 | `-p ${process.env.DB_PORT}`, 46 | // https://github.com/Kononnable/typeorm-model-generator/issues/204#issuecomment-533709527 47 | // If you use multiple databases, add comma. 48 | `-d ${db}`, // `-d ${db},`, 49 | `-u ${process.env.DB_USER}`, 50 | `-x ${process.env.DB_PASSWORD}`, 51 | `-e ${process.env.DB_TYPE}`, 52 | `-o ${MODEL_DIR}`, 53 | ]; 54 | 55 | try { 56 | // eslint-disable-next-line sonarjs/no-os-command-from-path 57 | spawnSync('typeorm-model-generator', generatorConfig, { stdio: 'pipe', shell: true }); 58 | } catch (error) { 59 | console.error(`> Failed to load '${db}' database.`, error); 60 | return; 61 | } 62 | 63 | const files = []; 64 | for (const file of readdirSync(MODEL_DIR)) { 65 | files.push(`export * from './${file.replace('.ts', '')}';`); 66 | } 67 | files.push(''); 68 | // export entity db tables 69 | // AS-IS import { Tablename } from './entity/dbname/tablename'; 70 | // TO-BE import { Tablename } from './entity/dbname'; 71 | writeFileSync(path.join(MODEL_DIR, 'index.ts'), files.join('\n')); 72 | 73 | console.log(`> '${db}' database entities has been created: ${MODEL_DIR}`); 74 | })().catch((error: unknown) => { 75 | console.error(error); 76 | }); 77 | -------------------------------------------------------------------------------- /bin/ormconfig.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/triple-slash-reference 2 | /// 3 | import * as dotenv from 'dotenv'; 4 | import { DataSource, type DataSourceOptions } from 'typeorm'; 5 | 6 | import { configuration } from '../src/config'; 7 | 8 | dotenv.config(); 9 | const ormconfig = async (): Promise => { 10 | const config = <{ db: DataSourceOptions }>await configuration(); 11 | 12 | return new DataSource({ 13 | ...config.db, 14 | entities: [`${__dirname}/../src/entity/**/*.{js,ts}`], 15 | migrations: [`${__dirname}/../src/migration/**/*.{js,ts}`], 16 | }); 17 | }; 18 | 19 | // eslint-disable-next-line import/no-default-export 20 | export default ormconfig(); 21 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef, @typescript-eslint/no-require-imports, @typescript-eslint/no-unused-vars */ 2 | const eslint = require('@eslint/js'); 3 | const importPlugin = require('eslint-plugin-import'); 4 | const jest = require('eslint-plugin-jest'); 5 | const prettierRecommended = require('eslint-plugin-prettier/recommended'); 6 | const sonarjs = require('eslint-plugin-sonarjs'); 7 | const tseslint = require('typescript-eslint'); 8 | 9 | // https://eslint.org/docs/latest/use/configure/configuration-files#typescript-configuration-files 10 | module.exports = (async function config() { 11 | const { default: stylistic } = await import('@stylistic/eslint-plugin'); 12 | const { default: love } = await import('eslint-config-love'); 13 | const { default: unicorn } = await import('eslint-plugin-unicorn'); 14 | 15 | return tseslint.config( 16 | eslint.configs.recommended, 17 | tseslint.configs.recommendedTypeChecked, 18 | tseslint.configs.strictTypeChecked, 19 | tseslint.configs.stylisticTypeChecked, 20 | love, 21 | stylistic.configs.recommended, 22 | prettierRecommended, 23 | unicorn.configs.recommended, 24 | sonarjs.configs.recommended, 25 | jest.configs['flat/recommended'], 26 | { 27 | ignores: ['**/node_modules/**', 'dist/**', 'src/entity/**'], 28 | }, 29 | { 30 | languageOptions: { 31 | parserOptions: { 32 | // projectService: true, 33 | projectService: { 34 | allowDefaultProject: ['*.js'], 35 | }, 36 | tsconfigRootDir: __dirname, 37 | }, 38 | }, 39 | plugins: { 40 | '@typescript-eslint': tseslint.plugin, 41 | jest, 42 | }, 43 | // https://github.com/import-js/eslint-plugin-import?tab=readme-ov-file#config---flat-with-config-in-typescript-eslint 44 | // Config "import/recommended": Key "plugins": Cannot redefine plugin "import". 45 | // extends: [importPlugin.flatConfigs.recommended, importPlugin.flatConfigs.typescript], 46 | settings: { 47 | 'import/resolver': { 48 | typescript: true, 49 | node: true, 50 | }, 51 | }, 52 | // These rules are for reference only. 53 | rules: { 54 | // #region eslint 55 | 'class-methods-use-this': 'off', 56 | complexity: ['error', 20], 57 | // https://github.com/typescript-eslint/typescript-eslint/issues/1277 58 | 'consistent-return': 'off', 59 | 'eslint-comments/require-description': 'off', 60 | 'func-names': 'off', 61 | 'max-len': ['error', { code: 140, ignoreTemplateLiterals: true, ignoreUrls: true }], 62 | 'newline-per-chained-call': 'off', 63 | 'no-await-in-loop': 'off', 64 | 'no-continue': 'off', 65 | // https://github.com/airbnb/javascript/issues/1342 66 | 'no-param-reassign': ['error', { props: false }], 67 | // https://github.com/airbnb/javascript/issues/1271 68 | // https://github.com/airbnb/javascript/blob/fd77bbebb77362ddecfef7aba3bf6abf7bdd81f2/packages/eslint-config-airbnb-base/rules/style.js#L340-L358 69 | 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], 70 | 'no-underscore-dangle': ['error', { allow: ['_id'] }], 71 | 'no-void': ['error', { allowAsStatement: true }], 72 | 'object-curly-newline': 'off', 73 | 'spaced-comment': ['error', 'always', { line: { markers: ['/', '#region', '#endregion'] } }], 74 | // #endregion 75 | 76 | // #region import 77 | 'import/no-default-export': 'error', 78 | 'import/order': [ 79 | 'error', 80 | { 81 | groups: [ 82 | ['builtin', 'external'], 83 | ['internal', 'parent', 'sibling', 'index'], 84 | ], 85 | 'newlines-between': 'always', 86 | alphabetize: { order: 'asc', caseInsensitive: true }, 87 | }, 88 | ], 89 | 'import/prefer-default-export': 'off', 90 | // #endregion 91 | 92 | // #region @typescript-eslint 93 | '@typescript-eslint/class-methods-use-this': 'off', 94 | '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'angle-bracket' }], 95 | '@typescript-eslint/init-declarations': ['error', 'never', { ignoreForLoopInit: true }], 96 | '@typescript-eslint/naming-convention': [ 97 | 'error', 98 | { selector: 'default', format: ['strictCamelCase'] }, 99 | { selector: 'variable', format: ['strictCamelCase', 'UPPER_CASE', 'StrictPascalCase'] }, 100 | // https://github.com/microsoft/TypeScript/issues/9458 101 | { selector: 'parameter', modifiers: ['unused'], format: ['strictCamelCase'], leadingUnderscore: 'allow' }, 102 | { selector: 'property', format: null }, 103 | { selector: 'typeProperty', format: null }, 104 | { selector: 'typeLike', format: ['StrictPascalCase'] }, 105 | { selector: 'enumMember', format: ['UPPER_CASE'] }, 106 | ], 107 | '@typescript-eslint/no-extraneous-class': 'off', 108 | '@typescript-eslint/no-magic-numbers': 'off', 109 | '@typescript-eslint/no-unsafe-member-access': 'off', 110 | '@typescript-eslint/no-unsafe-type-assertion': 'off', 111 | '@typescript-eslint/restrict-template-expressions': [ 112 | 'error', 113 | { allowAny: true, allowBoolean: true, allowNullish: true, allowNumber: true, allowRegExp: true }, 114 | ], 115 | '@typescript-eslint/prefer-destructuring': 'off', 116 | '@typescript-eslint/prefer-readonly': 'off', 117 | '@typescript-eslint/strict-boolean-expressions': 'off', 118 | // #endregion 119 | 120 | // #region stylistic 121 | '@stylistic/arrow-parens': ['error', 'always'], 122 | '@stylistic/brace-style': ['error', '1tbs'], 123 | '@stylistic/lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], 124 | '@stylistic/no-extra-parens': ['error', 'functions'], 125 | '@stylistic/object-curly-spacing': ['error', 'always'], 126 | '@stylistic/semi': ['error', 'always'], 127 | 128 | // for prettier 129 | '@stylistic/indent': 'off', 130 | '@stylistic/keyword-spacing': 'off', 131 | '@stylistic/member-delimiter-style': 'off', 132 | '@stylistic/operator-linebreak': 'off', 133 | // #endregion 134 | 135 | // #region sonarjs 136 | 'sonarjs/cognitive-complexity': ['error', 25], 137 | // https://community.sonarsource.com/t/eslint-plugin-sonarjs-performance-issues-on-large-codebase/138392 138 | 'sonarjs/no-commented-code': 'off', 139 | 'sonarjs/no-duplicate-string': 'off', 140 | 'sonarjs/no-nested-assignment': 'off', 141 | // #endregion 142 | 143 | // #region unicorn 144 | 'unicorn/no-null': 'off', 145 | 'unicorn/prevent-abbreviations': 'off', 146 | 'unicorn/prefer-module': 'off', 147 | 'unicorn/prefer-ternary': ['error', 'only-single-line'], 148 | 'unicorn/prefer-top-level-await': 'off', 149 | // #endregion 150 | }, 151 | }, 152 | ); 153 | })(); 154 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-default-export, max-len */ 2 | import type { Config } from 'jest'; 3 | 4 | /* 5 | * For a detailed explanation regarding each configuration property and type check, visit: 6 | * https://jestjs.io/docs/configuration 7 | */ 8 | const jestConfig: Config = { 9 | // All imported modules in your tests should be mocked automatically 10 | // automock: false, 11 | 12 | // Stop running tests after `n` failures 13 | // bail: 0, 14 | 15 | // The directory where Jest should store its cached dependency information 16 | // cacheDirectory: "/private/var/folders/hx/d5nhg4153c10j_4mb893l3lm0000gp/T/jest_dy", 17 | 18 | // Automatically clear mock calls, instances, contexts and results before every test 19 | clearMocks: true, 20 | 21 | // Indicates whether the coverage information should be collected while executing the test 22 | // collectCoverage: true, 23 | 24 | // An array of glob patterns indicating a set of files for which coverage information should be collected 25 | // collectCoverageFrom: undefined, 26 | 27 | // The directory where Jest should output its coverage files 28 | coverageDirectory: 'coverage', 29 | 30 | // An array of regexp pattern strings used to skip coverage collection 31 | // coveragePathIgnorePatterns: [ 32 | // "/node_modules/" 33 | // ], 34 | 35 | // Indicates which provider should be used to instrument code for coverage 36 | coverageProvider: 'v8', 37 | 38 | // A list of reporter names that Jest uses when writing coverage reports 39 | // coverageReporters: [ 40 | // "json", 41 | // "text", 42 | // "lcov", 43 | // "clover" 44 | // ], 45 | 46 | // An object that configures minimum threshold enforcement for coverage results 47 | // coverageThreshold: undefined, 48 | 49 | // A path to a custom dependency extractor 50 | // dependencyExtractor: undefined, 51 | 52 | // Make calling deprecated APIs throw helpful error messages 53 | // errorOnDeprecated: false, 54 | 55 | // The default configuration for fake timers 56 | // fakeTimers: { 57 | // "enableGlobally": false 58 | // }, 59 | 60 | // Force coverage collection from ignored files using an array of glob patterns 61 | // forceCoverageMatch: [], 62 | 63 | // A path to a module which exports an async function that is triggered once before all test suites 64 | // globalSetup: undefined, 65 | 66 | // A path to a module which exports an async function that is triggered once after all test suites 67 | // globalTeardown: undefined, 68 | 69 | // A set of global variables that need to be available in all test environments 70 | // globals: {}, 71 | 72 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 73 | // maxWorkers: "50%", 74 | 75 | // An array of directory names to be searched recursively up from the requiring module's location 76 | // moduleDirectories: [ 77 | // "node_modules" 78 | // ], 79 | 80 | // An array of file extensions your modules use 81 | // moduleFileExtensions: [ 82 | // "js", 83 | // "mjs", 84 | // "cjs", 85 | // "jsx", 86 | // "ts", 87 | // "tsx", 88 | // "json", 89 | // "node" 90 | // ], 91 | 92 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 93 | moduleNameMapper: { 94 | '#(.*)': '/src/$1', 95 | }, 96 | 97 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 98 | // modulePathIgnorePatterns: [], 99 | 100 | // Activates notifications for test results 101 | // notify: false, 102 | 103 | // An enum that specifies notification mode. Requires { notify: true } 104 | // notifyMode: "failure-change", 105 | 106 | // A preset that is used as a base for Jest's configuration 107 | preset: 'ts-jest', 108 | 109 | // Run tests from one or more projects 110 | // projects: undefined, 111 | 112 | // Use this configuration option to add custom reporters to Jest 113 | // reporters: undefined, 114 | 115 | // Automatically reset mock state before every test 116 | // resetMocks: false, 117 | 118 | // Reset the module registry before running each individual test 119 | // resetModules: false, 120 | 121 | // A path to a custom resolver 122 | // resolver: undefined, 123 | 124 | // Automatically restore mock state and implementation before every test 125 | // restoreMocks: false, 126 | 127 | // The root directory that Jest should scan for tests and modules within 128 | // rootDir: undefined, 129 | 130 | // A list of paths to directories that Jest should use to search for files in 131 | // roots: [ 132 | // "" 133 | // ], 134 | 135 | // Allows you to use a custom runner instead of Jest's default test runner 136 | // runner: "jest-runner", 137 | 138 | // The paths to modules that run some code to configure or set up the testing environment before each test 139 | setupFiles: ['dotenv/config'], 140 | 141 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 142 | // setupFilesAfterEnv: [], 143 | 144 | // The number of seconds after which a test is considered as slow and reported as such in the results. 145 | // slowTestThreshold: 5, 146 | 147 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 148 | // snapshotSerializers: [], 149 | 150 | // The test environment that will be used for testing 151 | testEnvironment: 'node', 152 | 153 | // Options that will be passed to the testEnvironment 154 | // testEnvironmentOptions: {}, 155 | 156 | // Adds a location field to test results 157 | // testLocationInResults: false, 158 | 159 | // The glob patterns Jest uses to detect test files 160 | testMatch: [ 161 | // "**/__tests__/**/*.[jt]s?(x)", 162 | // "**/?(*.)+(spec|test).[tj]s?(x)" 163 | '**/*.+(spec|test).[tj]s?(x)', 164 | ], 165 | 166 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 167 | testPathIgnorePatterns: ['/node_modules/', '/dist/', '/e2e/'], 168 | 169 | // The regexp pattern or array of patterns that Jest uses to detect test files 170 | // testRegex: [], 171 | 172 | // This option allows the use of a custom results processor 173 | // testResultsProcessor: undefined, 174 | 175 | // This option allows use of a custom test runner 176 | // testRunner: "jest-circus/runner", 177 | 178 | testTimeout: 30_000, 179 | 180 | // A map from regular expressions to paths to transformers 181 | transform: { 182 | '^.+\\.tsx?$': [ 183 | 'ts-jest', 184 | { 185 | tsconfig: 'tsconfig.json', 186 | isolatedModules: true, 187 | }, 188 | ], 189 | }, 190 | 191 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 192 | // transformIgnorePatterns: [ 193 | // "/node_modules/", 194 | // "\\.pnp\\.[^\\/]+$" 195 | // ], 196 | 197 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 198 | // unmockedModulePathPatterns: undefined, 199 | 200 | // Indicates whether each individual test should be reported during the run 201 | // verbose: undefined, 202 | 203 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 204 | // watchPathIgnorePatterns: [], 205 | 206 | // Whether to use watchman for file crawling 207 | // watchman: true, 208 | }; 209 | 210 | export default jestConfig; 211 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceRoot": "src", 3 | "entryFile": "app", 4 | "compilerOptions": { 5 | "tsConfigPath": "tsconfig.build.json", 6 | "deleteOutDir": true, 7 | "plugins": [{ 8 | "name": "@nestjs/graphql", 9 | "options": { "introspectComments": true } 10 | }, { 11 | "name": "@nestjs/swagger", 12 | "options": { "introspectComments": true } 13 | }] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-project-structure", 3 | "version": "0.0.0", 4 | "description": "Node.js framework NestJS project structure", 5 | "main": "dist/app", 6 | "engines": { 7 | "node": ">=22" 8 | }, 9 | "scripts": { 10 | "lint": "eslint \"src/**/*.ts\"", 11 | "lint:fix": "eslint --fix \"src/**/*.ts\"", 12 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 13 | "entity:load": "ts-node bin/entity", 14 | "entity:sync": "typeorm-ts-node-commonjs -d bin/ormconfig.ts schema:sync", 15 | "build": "nest build", 16 | "prestart": "npm run lint && npm run build", 17 | "start": "node dist/app", 18 | "start:dev": "nest start --watch", 19 | "start:debug": "nest start --debug --watch", 20 | "start:repl": "nest start --entryFile repl", 21 | "test": "jest --detectOpenHandles -i", 22 | "test:e2e": "jest --config ./test/jest.e2e.ts -i", 23 | "doc": "compodoc -p tsconfig.json -s -d docs", 24 | "doc:api": "npm run build && node dist/swagger" 25 | }, 26 | "dependencies": { 27 | "@apollo/server": "^4.12.0", 28 | "@nestjs/apollo": "^13.1.0", 29 | "@nestjs/axios": "^4.0.0", 30 | "@nestjs/common": "^11.1.0", 31 | "@nestjs/config": "^4.0.2", 32 | "@nestjs/core": "^11.1.0", 33 | "@nestjs/graphql": "^13.1.0", 34 | "@nestjs/jwt": "^11.0.0", 35 | "@nestjs/mapped-types": "^2.1.0", 36 | "@nestjs/passport": "^11.0.5", 37 | "@nestjs/platform-express": "^11.1.0", 38 | "@nestjs/serve-static": "^5.0.3", 39 | "@nestjs/swagger": "^11.1.5", 40 | "@nestjs/terminus": "^11.0.0", 41 | "@nestjs/typeorm": "^11.0.0", 42 | "axios": "^1.9.0", 43 | "class-transformer": "^0.5.1", 44 | "class-validator": "^0.14.1", 45 | "compression": "^1.8.0", 46 | "express-session": "^1.18.1", 47 | "graphql": "^16.11.0", 48 | "helmet": "^8.1.0", 49 | "mysql2": "^3.14.1", 50 | "nanoid": "^3.3.6", 51 | "nestjs-pino": "^4.4.0", 52 | "passport": "^0.7.0", 53 | "passport-jwt": "^4.0.1", 54 | "passport-local": "^1.0.0", 55 | "pino": "^9.6.0", 56 | "pino-http": "^10.4.0", 57 | "reflect-metadata": "^0.2.2", 58 | "rxjs": "^7.8.2", 59 | "typeorm": "^0.3.22" 60 | }, 61 | "devDependencies": { 62 | "@compodoc/compodoc": "^1.1.26", 63 | "@eslint/js": "^9.25.1", 64 | "@nestjs/cli": "^11.0.7", 65 | "@nestjs/testing": "^11.1.0", 66 | "@stylistic/eslint-plugin": "^4.2.0", 67 | "@types/compression": "^1.7.5", 68 | "@types/express": "^5.0.1", 69 | "@types/express-session": "^1.18.1", 70 | "@types/jest": "^29.5.14", 71 | "@types/node": "^22.15.3", 72 | "@types/passport": "^1.0.17", 73 | "@types/passport-jwt": "^4.0.1", 74 | "@types/passport-local": "^1.0.38", 75 | "@types/prompts": "^2.4.9", 76 | "@types/supertest": "^6.0.3", 77 | "esbuild": "^0.25.3", 78 | "eslint": "^9.25.1", 79 | "eslint-config-love": "^119.0.0", 80 | "eslint-config-prettier": "^10.1.2", 81 | "eslint-import-resolver-typescript": "^4.3.4", 82 | "eslint-plugin-import": "^2.31.0", 83 | "eslint-plugin-jest": "^28.11.0", 84 | "eslint-plugin-prettier": "^5.2.6", 85 | "eslint-plugin-sonarjs": "^3.0.2", 86 | "eslint-plugin-unicorn": "^59.0.0", 87 | "jest": "^29.7.0", 88 | "jest-mock-extended": "^3.0.7", 89 | "pino-pretty": "^13.0.0", 90 | "prettier": "^3.5.3", 91 | "prompts": "^2.4.2", 92 | "rimraf": "^6.0.1", 93 | "supertest": "^7.1.0", 94 | "ts-jest": "^29.3.2", 95 | "ts-node": "^10.9.2", 96 | "typeorm-model-generator": "^0.4.6-no-engines", 97 | "typescript": "~5.8.3", 98 | "typescript-eslint": "^8.31.1" 99 | }, 100 | "repository": { 101 | "type": "git", 102 | "url": "https://github.com/CatsMiaow/nestjs-project-structure.git" 103 | }, 104 | "keywords": [ 105 | "Node.js", 106 | "NestJS", 107 | "TypeScript" 108 | ], 109 | "homepage": "https://github.com/CatsMiaow/nestjs-project-structure#readme", 110 | "author": "CatsMiaow", 111 | "license": "MIT" 112 | } 113 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | nestjs-project-structure 8 | 9 | 10 | 11 | 12 | Hello, world! 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/app.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { INestApplication } from '@nestjs/common'; 2 | import compression from 'compression'; 3 | import session from 'express-session'; 4 | import helmet from 'helmet'; 5 | import passport from 'passport'; 6 | 7 | export function middleware(app: INestApplication): INestApplication { 8 | const isProduction = process.env.NODE_ENV === 'production'; 9 | 10 | app.use(compression()); 11 | app.use( 12 | session({ 13 | // Requires 'store' setup for production 14 | secret: 'tEsTeD', 15 | resave: false, 16 | saveUninitialized: true, 17 | cookie: { secure: isProduction }, 18 | }), 19 | ); 20 | app.use(passport.initialize()); 21 | app.use(passport.session()); 22 | // https://github.com/graphql/graphql-playground/issues/1283#issuecomment-703631091 23 | // https://github.com/graphql/graphql-playground/issues/1283#issuecomment-1012913186 24 | app.use( 25 | helmet({ 26 | contentSecurityPolicy: isProduction ? undefined : false, 27 | crossOriginEmbedderPolicy: isProduction ? undefined : false, 28 | }), 29 | ); 30 | // app.enableCors(); 31 | 32 | return app; 33 | } 34 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, ValidationPipe } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { APP_FILTER, APP_PIPE, RouterModule } from '@nestjs/core'; 4 | import { ServeStaticModule } from '@nestjs/serve-static'; 5 | import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; 6 | import { LoggerModule } from 'nestjs-pino'; 7 | 8 | import { AuthModule } from './auth'; 9 | import { BaseModule } from './base'; 10 | import { CommonModule, ExceptionsFilter } from './common'; 11 | import { configuration, loggerOptions } from './config'; 12 | import { SampleModule as DebugSampleModule } from './debug'; 13 | import { GqlModule } from './gql'; 14 | import { SampleModule } from './sample'; 15 | 16 | @Module({ 17 | imports: [ 18 | // https://getpino.io 19 | // https://github.com/iamolegga/nestjs-pino 20 | LoggerModule.forRoot(loggerOptions), 21 | // Configuration 22 | // https://docs.nestjs.com/techniques/configuration 23 | ConfigModule.forRoot({ 24 | isGlobal: true, 25 | load: [configuration], 26 | }), 27 | // Database 28 | // https://docs.nestjs.com/techniques/database 29 | TypeOrmModule.forRootAsync({ 30 | useFactory: (config: ConfigService) => ({ 31 | ...config.get('db'), 32 | }), 33 | inject: [ConfigService], 34 | }), 35 | // Static Folder 36 | // https://docs.nestjs.com/recipes/serve-static 37 | // https://docs.nestjs.com/techniques/mvc 38 | ServeStaticModule.forRoot({ 39 | rootPath: `${__dirname}/../public`, 40 | renderPath: '/', 41 | }), 42 | // Service Modules 43 | AuthModule, // Global for Middleware 44 | CommonModule, // Global 45 | BaseModule, 46 | SampleModule, 47 | GqlModule, 48 | DebugSampleModule, 49 | // Module Router 50 | // https://docs.nestjs.com/recipes/router-module 51 | RouterModule.register([ 52 | { 53 | path: 'test', 54 | module: SampleModule, 55 | }, 56 | { 57 | path: 'test', 58 | module: DebugSampleModule, 59 | }, 60 | ]), 61 | ], 62 | providers: [ 63 | // Global Guard, Authentication check on all routers 64 | // { provide: APP_GUARD, useClass: AuthenticatedGuard }, 65 | // Global Filter, Exception check 66 | { provide: APP_FILTER, useClass: ExceptionsFilter }, 67 | // Global Pipe, Validation check 68 | // https://docs.nestjs.com/pipes#global-scoped-pipes 69 | // https://docs.nestjs.com/techniques/validation 70 | { 71 | provide: APP_PIPE, 72 | useValue: new ValidationPipe({ 73 | // disableErrorMessages: true, 74 | transform: true, // transform object to DTO class 75 | whitelist: true, 76 | }), 77 | }, 78 | ], 79 | }) 80 | export class AppModule {} 81 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { Logger as NestLogger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import type { NestExpressApplication } from '@nestjs/platform-express'; 4 | import { Logger, LoggerErrorInterceptor } from 'nestjs-pino'; 5 | 6 | import { middleware } from './app.middleware'; 7 | import { AppModule } from './app.module'; 8 | 9 | /** 10 | * https://docs.nestjs.com 11 | * https://github.com/nestjs/nest/tree/master/sample 12 | * https://github.com/nestjs/nest/issues/2249#issuecomment-494734673 13 | */ 14 | async function bootstrap(): Promise { 15 | const isProduction = process.env.NODE_ENV === 'production'; 16 | const app = await NestFactory.create(AppModule, { 17 | bufferLogs: true, 18 | }); 19 | 20 | app.useLogger(app.get(Logger)); 21 | app.useGlobalInterceptors(new LoggerErrorInterceptor()); 22 | 23 | if (isProduction) { 24 | app.enable('trust proxy'); 25 | } 26 | 27 | // Express Middleware 28 | middleware(app); 29 | 30 | app.enableShutdownHooks(); 31 | await app.listen(process.env.PORT || 3000); 32 | 33 | return await app.getUrl(); 34 | } 35 | 36 | void (async (): Promise => { 37 | try { 38 | const url = await bootstrap(); 39 | NestLogger.log(url, 'Bootstrap'); 40 | } catch (error) { 41 | NestLogger.error(error, 'Bootstrap'); 42 | } 43 | })(); 44 | -------------------------------------------------------------------------------- /src/auth/auth.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JwtSign { 2 | access_token: string; 3 | refresh_token: string; 4 | } 5 | 6 | export interface JwtPayload { 7 | sub: string; 8 | username: string; 9 | roles: string[]; 10 | } 11 | 12 | export interface Payload { 13 | userId: string; 14 | username: string; 15 | roles: string[]; 16 | } 17 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | 5 | import { AuthSerializer } from './auth.serializer'; 6 | import { AuthService } from './auth.service'; 7 | import { LocalStrategy, JwtStrategy, JwtVerifyStrategy } from './strategies'; 8 | import { UserModule } from '../shared/user'; 9 | 10 | @Global() 11 | @Module({ 12 | imports: [ 13 | UserModule, 14 | JwtModule.registerAsync({ 15 | useFactory: (config: ConfigService) => ({ 16 | secret: config.get('jwtSecret'), 17 | signOptions: { expiresIn: '1d' }, 18 | }), 19 | inject: [ConfigService], 20 | }), 21 | ], 22 | providers: [AuthService, AuthSerializer, LocalStrategy, JwtStrategy, JwtVerifyStrategy], 23 | exports: [AuthService], 24 | }) 25 | export class AuthModule {} 26 | -------------------------------------------------------------------------------- /src/auth/auth.serializer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportSerializer } from '@nestjs/passport'; 3 | 4 | import type { Payload } from './auth.interface'; 5 | 6 | @Injectable() 7 | export class AuthSerializer extends PassportSerializer { 8 | public serializeUser(user: Payload, done: (err: Error | null, data?: Payload) => void): void { 9 | done(null, user); 10 | } 11 | 12 | public deserializeUser(data: Payload, done: (err: Error | null, user?: Payload) => void): void { 13 | try { 14 | // const user = await fetchMore(); 15 | done(null, data); 16 | } catch (error) { 17 | done(error); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | 5 | import type { JwtPayload, JwtSign, Payload } from './auth.interface'; 6 | import { User, UserService } from '../shared/user'; 7 | 8 | @Injectable() 9 | export class AuthService { 10 | constructor( 11 | private jwt: JwtService, 12 | private user: UserService, 13 | private config: ConfigService, 14 | ) {} 15 | 16 | public async validateUser(username: string, password: string): Promise { 17 | const user = await this.user.fetch(username); 18 | 19 | if (user.password === password) { 20 | // eslint-disable-next-line sonarjs/no-unused-vars 21 | const { password: pass, ...result } = user; 22 | return result; 23 | } 24 | 25 | return null; 26 | } 27 | 28 | public validateRefreshToken(data: Payload, refreshToken: string): boolean { 29 | if (!this.jwt.verify(refreshToken, { secret: this.config.get('jwtRefreshSecret') })) { 30 | return false; 31 | } 32 | 33 | const payload = this.jwt.decode<{ sub: string }>(refreshToken); 34 | return payload.sub === data.userId; 35 | } 36 | 37 | public jwtSign(data: Payload): JwtSign { 38 | const payload: JwtPayload = { sub: data.userId, username: data.username, roles: data.roles }; 39 | 40 | return { 41 | access_token: this.jwt.sign(payload), 42 | refresh_token: this.getRefreshToken(payload.sub), 43 | }; 44 | } 45 | 46 | public getPayload(token: string): Payload | null { 47 | try { 48 | const payload = this.jwt.decode(token); 49 | if (!payload) { 50 | return null; 51 | } 52 | 53 | return { userId: payload.sub, username: payload.username, roles: payload.roles }; 54 | } catch { 55 | // Unexpected token i in JSON at position XX 56 | return null; 57 | } 58 | } 59 | 60 | private getRefreshToken(sub: string): string { 61 | return this.jwt.sign( 62 | { sub }, 63 | { 64 | secret: this.config.get('jwtRefreshSecret'), 65 | expiresIn: '7d', // Set greater than the expiresIn of the access_token 66 | }, 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/auth/guards/authenticated.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql'; 4 | import type { Request } from 'express'; 5 | 6 | @Injectable() 7 | export class AuthenticatedGuard implements CanActivate { 8 | constructor(private reflector: Reflector) {} 9 | 10 | public canActivate(context: ExecutionContext): boolean { 11 | // https://github.com/nestjs/nest/issues/964#issuecomment-480834786 12 | const isPublic = this.reflector.get('isPublic', context.getHandler()); 13 | if (isPublic) { 14 | return true; 15 | } 16 | 17 | const request = this.getRequest(context); 18 | return request.isAuthenticated(); 19 | } 20 | 21 | public getRequest(context: ExecutionContext): Request { 22 | if (context.getType() === 'graphql') { 23 | const ctx = GqlExecutionContext.create(context).getContext<{ req: Request }>(); 24 | return ctx.req; 25 | } 26 | 27 | return context.switchToHttp().getRequest(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/auth/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authenticated.guard'; 2 | export * from './jwt-auth.guard'; 3 | export * from './jwt-verify.guard'; 4 | export * from './local-auth.guard'; 5 | export * from './local-login.guard'; 6 | -------------------------------------------------------------------------------- /src/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import type { Request } from 'express'; 5 | 6 | @Injectable() 7 | export class JwtAuthGuard extends AuthGuard('jwt') { 8 | public override getRequest(context: ExecutionContext): Request { 9 | if (context.getType() === 'graphql') { 10 | const ctx = GqlExecutionContext.create(context).getContext<{ req: Request }>(); 11 | return ctx.req; 12 | } 13 | 14 | return context.switchToHttp().getRequest(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/auth/guards/jwt-verify.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import type { Request } from 'express'; 5 | 6 | @Injectable() 7 | export class JwtVerifyGuard extends AuthGuard('jwt-verify') { 8 | public override getRequest(context: ExecutionContext): Request { 9 | if (context.getType() === 'graphql') { 10 | const ctx = GqlExecutionContext.create(context).getContext<{ req: Request }>(); 11 | return ctx.req; 12 | } 13 | 14 | return context.switchToHttp().getRequest(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/auth/guards/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /src/auth/guards/local-login.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import type { Request } from 'express'; 4 | 5 | @Injectable() 6 | export class LocalLoginGuard extends AuthGuard('local') implements CanActivate { 7 | public override async canActivate(context: ExecutionContext): Promise { 8 | const result = await super.canActivate(context); 9 | const request = context.switchToHttp().getRequest(); 10 | await super.logIn(request); 11 | 12 | return result; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './guards'; 2 | export * from './strategies'; 3 | export type * from './auth.interface'; 4 | export * from './auth.module'; 5 | export * from './auth.service'; 6 | -------------------------------------------------------------------------------- /src/auth/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt-verify.strategy'; 2 | export * from './jwt.strategy'; 3 | export * from './local.strategy'; 4 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt-verify.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | 6 | import type { JwtPayload, Payload } from '../auth.interface'; 7 | 8 | @Injectable() 9 | export class JwtVerifyStrategy extends PassportStrategy(Strategy, 'jwt-verify') { 10 | constructor(config: ConfigService) { 11 | super({ 12 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 13 | ignoreExpiration: true, // Expiration of the access_token is not checked when processing the refresh_token. 14 | secretOrKey: config.getOrThrow('jwtSecret'), 15 | }); 16 | } 17 | 18 | public validate(payload: JwtPayload): Payload { 19 | return { userId: payload.sub, username: payload.username, roles: payload.roles }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | 6 | import type { JwtPayload, Payload } from '../auth.interface'; 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy) { 10 | constructor(config: ConfigService) { 11 | super({ 12 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 13 | ignoreExpiration: false, 14 | secretOrKey: config.getOrThrow('jwtSecret'), 15 | }); 16 | } 17 | 18 | public validate(payload: JwtPayload): Payload { 19 | return { userId: payload.sub, username: payload.username, roles: payload.roles }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-local'; 4 | 5 | import type { Payload } from '../auth.interface'; 6 | import { AuthService } from '../auth.service'; 7 | 8 | @Injectable() 9 | export class LocalStrategy extends PassportStrategy(Strategy) { 10 | constructor(private auth: AuthService) { 11 | super(); 12 | } 13 | 14 | public async validate(username: string, password: string): Promise { 15 | const user = await this.auth.validateUser(username, password); 16 | if (!user) { 17 | throw new UnauthorizedException('NotFoundUser'); 18 | } 19 | 20 | return { userId: user.id, username: user.name, roles: user.roles }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/base/base.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from '@nestjs/axios'; 2 | import { Module } from '@nestjs/common'; 3 | import { TerminusModule } from '@nestjs/terminus'; 4 | 5 | import * as controllers from './controllers'; 6 | 7 | @Module({ 8 | imports: [TerminusModule, HttpModule], // Authentication 9 | controllers: Object.values(controllers), 10 | }) 11 | export class BaseModule {} 12 | -------------------------------------------------------------------------------- /src/base/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, UseGuards, Req, Res, UnauthorizedException, Body } from '@nestjs/common'; 2 | import type { Request, Response } from 'express'; 3 | 4 | import { 5 | AuthService, 6 | LocalLoginGuard, 7 | Payload, 8 | AuthenticatedGuard, 9 | LocalAuthGuard, 10 | JwtAuthGuard, 11 | JwtSign, 12 | JwtVerifyGuard, 13 | } from '../../auth'; 14 | import { ReqUser } from '../../common'; 15 | 16 | /** 17 | * https://docs.nestjs.com/techniques/authentication 18 | */ 19 | @Controller() 20 | export class AuthController { 21 | constructor(private auth: AuthService) {} 22 | 23 | /** 24 | * See test/e2e/local-auth.spec.ts 25 | * need username, password in body 26 | * skip guard to @Public when using global guard 27 | */ 28 | @Post('login') 29 | @UseGuards(LocalLoginGuard) 30 | public login(@ReqUser() user: Payload): Payload { 31 | return user; 32 | } 33 | 34 | @Get('logout') 35 | public logout(@Req() req: Request, @Res() res: Response): void { 36 | req.logout(() => { 37 | res.redirect('/'); 38 | }); 39 | } 40 | 41 | @Get('check') 42 | @UseGuards(AuthenticatedGuard) 43 | public check(@ReqUser() user: Payload): Payload { 44 | return user; 45 | } 46 | 47 | /** 48 | * See test/e2e/jwt-auth.spec.ts 49 | */ 50 | @UseGuards(LocalAuthGuard) 51 | @Post('jwt/login') 52 | public jwtLogin(@ReqUser() user: Payload): JwtSign { 53 | return this.auth.jwtSign(user); 54 | } 55 | 56 | @UseGuards(JwtAuthGuard) 57 | @Get('jwt/check') 58 | public jwtCheck(@ReqUser() user: Payload): Payload { 59 | return user; 60 | } 61 | 62 | // Only verify is performed without checking the expiration of the access_token. 63 | @UseGuards(JwtVerifyGuard) 64 | @Post('jwt/refresh') 65 | public jwtRefresh(@ReqUser() user: Payload, @Body('refresh_token') token?: string): JwtSign { 66 | if (!token || !this.auth.validateRefreshToken(user, token)) { 67 | throw new UnauthorizedException('InvalidRefreshToken'); 68 | } 69 | 70 | return this.auth.jwtSign(user); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/base/controllers/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { 3 | HealthCheck, 4 | HealthCheckResult, 5 | HealthCheckService, 6 | HealthIndicatorResult, 7 | HttpHealthIndicator, 8 | TypeOrmHealthIndicator, 9 | } from '@nestjs/terminus'; 10 | 11 | import { Public } from '../../common'; 12 | 13 | /** 14 | * https://docs.nestjs.com/recipes/terminus 15 | */ 16 | @Controller() 17 | export class HealthController { 18 | constructor( 19 | private health: HealthCheckService, 20 | private http: HttpHealthIndicator, 21 | private db: TypeOrmHealthIndicator, 22 | ) {} 23 | 24 | @Public() 25 | @Get('health') 26 | @HealthCheck() 27 | public async check(): Promise { 28 | return await this.health.check([ 29 | async (): Promise => await this.http.pingCheck('dns', 'https://1.1.1.1'), 30 | async (): Promise => await this.db.pingCheck('database'), 31 | ]); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/base/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.controller'; 2 | export * from './health.controller'; 3 | -------------------------------------------------------------------------------- /src/base/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base.module'; 2 | -------------------------------------------------------------------------------- /src/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | 3 | import { LoggerContextMiddleware } from './middleware'; 4 | import * as providers from './providers'; 5 | 6 | const services = Object.values(providers); 7 | 8 | @Global() 9 | @Module({ 10 | providers: services, 11 | exports: services, 12 | }) 13 | export class CommonModule implements NestModule { 14 | // Global Middleware 15 | public configure(consumer: MiddlewareConsumer): void { 16 | consumer.apply(LoggerContextMiddleware).forRoutes('*'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/common/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public.decorator'; 2 | export * from './req-user.decorator'; 3 | export * from './roles.decorator'; 4 | -------------------------------------------------------------------------------- /src/common/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata, type CustomDecorator } from '@nestjs/common'; 2 | 3 | export const Public = (): CustomDecorator => SetMetadata('isPublic', true); 4 | -------------------------------------------------------------------------------- /src/common/decorators/req-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; 2 | import { type GqlContextType, GqlExecutionContext } from '@nestjs/graphql'; 3 | import type { Request } from 'express'; 4 | 5 | export const ReqUser = createParamDecorator((_data: unknown, context: ExecutionContext) => { 6 | let request: Request; 7 | 8 | if (context.getType() === 'graphql') { 9 | const ctx = GqlExecutionContext.create(context).getContext<{ req: Request }>(); 10 | request = ctx.req; 11 | } else { 12 | request = context.switchToHttp().getRequest(); 13 | } 14 | 15 | return request.user; 16 | }); 17 | -------------------------------------------------------------------------------- /src/common/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata, type CustomDecorator } from '@nestjs/common'; 2 | 3 | export const Roles = (...roles: string[]): CustomDecorator => SetMetadata('roles', roles); 4 | -------------------------------------------------------------------------------- /src/common/filters/exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, HttpException, HttpStatus, Logger } from '@nestjs/common'; 2 | import { BaseExceptionFilter } from '@nestjs/core'; 3 | import { GqlArgumentsHost, GqlContextType, GqlExceptionFilter } from '@nestjs/graphql'; 4 | 5 | @Catch() 6 | export class ExceptionsFilter extends BaseExceptionFilter implements GqlExceptionFilter { 7 | private readonly logger: Logger = new Logger(); 8 | 9 | public override catch(exception: unknown, host: ArgumentsHost): void { 10 | let args: unknown; 11 | if (host.getType() === 'graphql') { 12 | const gqlHost = GqlArgumentsHost.create(host); 13 | const { 14 | req: { 15 | body: { operationName, variables }, 16 | }, 17 | } = gqlHost.getContext<{ req: { body: { operationName: string; variables: Record } } }>(); 18 | args = `${operationName} ${JSON.stringify(variables)}`; 19 | } else { 20 | super.catch(exception, host); 21 | // const req = host.switchToHttp().getRequest(); 22 | // req.method, req.originalUrl... 23 | // args = req.body; 24 | } 25 | 26 | const status = this.getHttpStatus(exception); 27 | if (status === HttpStatus.INTERNAL_SERVER_ERROR) { 28 | if (exception instanceof Error) { 29 | this.logger.error({ err: exception, args }); 30 | } else { 31 | // Error Notifications 32 | this.logger.error('UnhandledException', exception); 33 | } 34 | } 35 | } 36 | 37 | private getHttpStatus(exception: unknown): HttpStatus { 38 | return exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/common/filters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './exceptions.filter'; 2 | -------------------------------------------------------------------------------- /src/common/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './roles.guard'; 2 | -------------------------------------------------------------------------------- /src/common/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql'; 4 | import type { Request } from 'express'; 5 | 6 | @Injectable() 7 | export class RolesGuard implements CanActivate { 8 | constructor(private reflector: Reflector) {} 9 | 10 | public canActivate(context: ExecutionContext): boolean { 11 | const roles = this.reflector.getAllAndOverride('roles', [ 12 | context.getHandler(), // Method Roles 13 | context.getClass(), // Controller Roles 14 | ]); 15 | 16 | if (!roles) { 17 | return true; 18 | } 19 | 20 | let request: Request; 21 | if (context.getType() === 'graphql') { 22 | const ctx = GqlExecutionContext.create(context).getContext<{ req: Request }>(); 23 | request = ctx.req; 24 | } else { 25 | request = context.switchToHttp().getRequest(); 26 | } 27 | 28 | const { user } = request; 29 | if (!user) { 30 | return false; 31 | } 32 | 33 | return user.roles.some((role: string) => roles.includes(role)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './decorators'; 2 | export * from './filters'; 3 | export * from './guards'; 4 | export * from './middleware'; 5 | export * from './providers'; 6 | export * from './common.module'; 7 | -------------------------------------------------------------------------------- /src/common/middleware/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger-context.middleware'; 2 | -------------------------------------------------------------------------------- /src/common/middleware/logger-context.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import type { Request, Response } from 'express'; 3 | import { PinoLogger } from 'nestjs-pino'; 4 | 5 | import { AuthService } from '../../auth'; 6 | 7 | @Injectable() 8 | export class LoggerContextMiddleware implements NestMiddleware { 9 | // GraphQL logging uses the apollo plugins. 10 | // https://docs.nestjs.com/graphql/plugins 11 | // https://docs.nestjs.com/graphql/field-middleware 12 | 13 | constructor( 14 | private readonly logger: PinoLogger, 15 | private auth: AuthService, 16 | ) {} 17 | 18 | public use(req: Request, _res: Response, next: () => void): void { 19 | const authorization = req.header('authorization'); 20 | 21 | const user = authorization?.startsWith('Bearer') ? this.auth.getPayload(authorization.split(' ')[1]) : req.user; 22 | 23 | const userId = user?.userId; 24 | // for https://github.com/iamolegga/nestjs-pino/issues/608 25 | req.customProps = { userId }; 26 | // Add extra fields to share in logger context 27 | this.logger.assign(req.customProps); 28 | 29 | next(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/common/providers/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService as NestConfig, Path, PathValue } from '@nestjs/config'; 3 | 4 | import type { Config } from '../../config'; 5 | 6 | @Injectable() 7 | export class ConfigService extends NestConfig { 8 | public override get

>(path: P): PathValue { 9 | const value = super.get(path, { infer: true }); 10 | 11 | if (value === undefined) { 12 | throw new Error(`NotFoundConfig: ${path}`); 13 | } 14 | 15 | return value; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/common/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config.service'; 2 | export * from './util.service'; 3 | -------------------------------------------------------------------------------- /src/common/providers/util.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | type TemplateParameter = any[]; 5 | 6 | @Injectable() 7 | export class UtilService { 8 | public template(templateData: TemplateStringsArray, param: TemplateParameter, delimiter = '\n'): string { 9 | // eslint-disable-next-line @typescript-eslint/init-declarations 10 | let output = ''; 11 | for (const [i, element] of param.entries()) { 12 | output += `${templateData[i]}${element}`; 13 | } 14 | output += templateData[param.length]; 15 | 16 | const lines: string[] = output.split(/(?:\r\n|\n|\r)/); 17 | 18 | return lines 19 | .map((text: string) => text.replaceAll(/^\s+/gm, '')) 20 | .join(delimiter) 21 | .trim(); 22 | } 23 | 24 | public pre(templateData: TemplateStringsArray, ...param: TemplateParameter): string { 25 | return this.template(templateData, param, '\n'); 26 | } 27 | 28 | public line(templateData: TemplateStringsArray, ...param: TemplateParameter): string { 29 | return this.template(templateData, param, ' '); 30 | } 31 | 32 | public removeUndefined(argv: object): Record { 33 | // https://stackoverflow.com/questions/25421233 34 | // JSON.parse(JSON.stringify(args)); 35 | return Object.fromEntries(Object.entries(argv).filter(([, value]: [string, unknown]) => value !== undefined)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/config/README.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | 4 | > With the template literal types now available in TypeScript v4.2, we are able to implement a new infer feature that lets us infer the type of a nested custom configuration object's property, even when using dot notation 5 | 6 | Since the `infer` option is non-default and needs to be added each time it is used, we implement it as a default by extending ConfigService in Nest. 7 | 8 | See [ConfigService](../common/providers/config.service.ts) of `CommonModule` 9 | 10 | ## Usage example 11 | 12 | See [sample](../sample/controllers/sample.controller.ts#L28-L31) method of `SampleController` 13 | 14 | ![example](https://user-images.githubusercontent.com/1300172/127599201-8491e7bb-76f3-4dbc-9a62-97b6832bb882.png) 15 | -------------------------------------------------------------------------------- /src/config/config.interface.ts: -------------------------------------------------------------------------------- 1 | import type { config as base } from './envs/default'; 2 | import type { config as production } from './envs/production'; 3 | 4 | export type Objectype = Record; 5 | export type Default = typeof base; 6 | export type Production = typeof production; 7 | export type Config = Default & Production; 8 | -------------------------------------------------------------------------------- /src/config/configuration.ts: -------------------------------------------------------------------------------- 1 | import type { Config, Default, Objectype, Production } from './config.interface'; 2 | 3 | const util = { 4 | isObject(value: T): value is T & Objectype { 5 | return value != null && typeof value === 'object' && !Array.isArray(value); 6 | }, 7 | merge(target: T, source: U): T & U { 8 | for (const key of Object.keys(source)) { 9 | const targetValue = target[key]; 10 | const sourceValue = source[key]; 11 | if (this.isObject(targetValue) && this.isObject(sourceValue)) { 12 | Object.assign(sourceValue, this.merge(targetValue, sourceValue)); 13 | } 14 | } 15 | 16 | return { ...target, ...source }; 17 | }, 18 | }; 19 | 20 | export const configuration = async (): Promise => { 21 | const { config } = <{ config: Default }>await import(`${__dirname}/envs/default`); 22 | const { config: environment } = <{ config: Production }>await import(`${__dirname}/envs/${process.env.NODE_ENV || 'development'}`); 23 | 24 | // object deep merge 25 | return util.merge(config, environment); 26 | }; 27 | -------------------------------------------------------------------------------- /src/config/envs/default.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | db: { 3 | // entities: [`${__dirname}/../../entity/**/*.{js,ts}`], 4 | // subscribers: [`${__dirname}/../../subscriber/**/*.{js,ts}`], 5 | // migrations: [`${__dirname}/../../migration/**/*.{js,ts}`], 6 | }, 7 | graphql: { 8 | debug: true, 9 | playground: { 10 | settings: { 11 | 'request.credentials': 'include', 12 | }, 13 | }, 14 | autoSchemaFile: true, 15 | autoTransformHttpErrors: true, 16 | // cors: { credentials: true }, 17 | // sortSchema: true, 18 | // installSubscriptionHandlers: true, 19 | }, 20 | hello: 'world', 21 | jwtSecret: process.env.JWT_SECRET, 22 | jwtRefreshSecret: process.env.JWT_REFRESH_SECRET, 23 | }; 24 | -------------------------------------------------------------------------------- /src/config/envs/development.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | db: { 3 | type: process.env.DB_TYPE || 'mysql', 4 | synchronize: false, 5 | logging: true, 6 | host: process.env.DB_HOST || '127.0.0.1', 7 | port: process.env.DB_PORT || 3306, 8 | username: process.env.DB_USER || 'username', 9 | password: process.env.DB_PASSWORD || 'password', 10 | database: process.env.DB_NAME || 'dbname', 11 | extra: { 12 | connectionLimit: 10, 13 | }, 14 | autoLoadEntities: true, 15 | }, 16 | foo: 'dev-bar', 17 | }; 18 | -------------------------------------------------------------------------------- /src/config/envs/production.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | db: { 3 | type: process.env.DB_TYPE || 'mysql', 4 | synchronize: false, 5 | logging: false, 6 | replication: { 7 | master: { 8 | host: process.env.DB_HOST || 'masterHost', 9 | port: process.env.DB_PORT || 3306, 10 | username: process.env.DB_USER || 'username', 11 | password: process.env.DB_PASSWORD || 'password', 12 | database: process.env.DB_NAME || 'dbname', 13 | }, 14 | slaves: [ 15 | { 16 | // fix if necessary 17 | host: 'slaveHost', 18 | port: 3306, 19 | username: 'username', 20 | password: process.env.DB_PASSWORD || 'password', 21 | database: 'dbname', 22 | }, 23 | ], 24 | }, 25 | extra: { 26 | connectionLimit: 30, 27 | }, 28 | autoLoadEntities: true, 29 | }, 30 | graphql: { 31 | debug: false, 32 | playground: false, 33 | }, 34 | foo: 'pro-bar', 35 | }; 36 | -------------------------------------------------------------------------------- /src/config/envs/test.ts: -------------------------------------------------------------------------------- 1 | // export * from './development'; 2 | export const config = { 3 | db: { 4 | type: 'mysql', 5 | synchronize: false, 6 | logging: false, 7 | host: process.env.DB_HOST || '127.0.0.1', 8 | port: process.env.DB_PORT || 3306, 9 | username: process.env.DB_USER || 'username', 10 | password: process.env.DB_PASSWORD || 'password', 11 | database: process.env.DB_NAME || 'dbname', 12 | extra: { 13 | connectionLimit: 5, 14 | }, 15 | autoLoadEntities: true, 16 | }, 17 | graphql: { 18 | playground: false, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './config.interface'; 2 | export * from './configuration'; 3 | export * from './logger.config'; 4 | -------------------------------------------------------------------------------- /src/config/logger.config.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from 'express'; 2 | import { nanoid } from 'nanoid'; 3 | import type { Params } from 'nestjs-pino'; 4 | import { multistream } from 'pino'; 5 | import type { ReqId } from 'pino-http'; 6 | 7 | const passUrl = new Set(['/health', '/graphql']); 8 | 9 | export const loggerOptions: Params = { 10 | pinoHttp: [ 11 | { 12 | // https://getpino.io/#/docs/api?id=timestamp-boolean-function 13 | // Change time value in production log. 14 | // timestamp: stdTimeFunctions.isoTime, 15 | quietReqLogger: true, 16 | genReqId: (req): ReqId => (req).header('X-Request-Id') ?? nanoid(), 17 | ...(process.env.NODE_ENV === 'production' 18 | ? {} 19 | : { 20 | level: 'debug', 21 | // https://github.com/pinojs/pino-pretty 22 | transport: { 23 | target: 'pino-pretty', 24 | options: { sync: true, singleLine: true }, 25 | }, 26 | }), 27 | autoLogging: { 28 | ignore: (req) => passUrl.has((req).originalUrl), 29 | }, 30 | customProps: (req) => (req).customProps, 31 | }, 32 | multistream( 33 | [ 34 | // https://getpino.io/#/docs/help?id=log-to-different-streams 35 | { level: 'debug', stream: process.stdout }, 36 | { level: 'error', stream: process.stderr }, 37 | { level: 'fatal', stream: process.stderr }, 38 | ], 39 | { dedupe: true }, 40 | ), 41 | ], 42 | }; 43 | -------------------------------------------------------------------------------- /src/debug/README.md: -------------------------------------------------------------------------------- 1 | # Debug 2 | 3 | `DebugModule` registers a decorator that outputs logs along with the execution time in all methods of the `controllers` and `providers` of the module (including import module) to which the `@Debug` decorator is applied. \ 4 | Instead of adding a log to every method one by one, you can leave a log of execution of all methods with one decorator. \ 5 | It can also be applied only to specific classes or methods. 6 | 7 | ## Usage 8 | 9 | See the [sample](./sample) folder for examples. 10 | 11 | ### Module 12 | 13 | ```ts 14 | @Debug('ModuleContext') 15 | @Module({ 16 | imports: [DebugModule.forRoot()], 17 | controllers: [...], 18 | providers: [...], 19 | }) 20 | export class AppModule {} 21 | ``` 22 | 23 | To exclude a specific class within a module 24 | 25 | ```ts 26 | @Debug({ context: 'ModuleContext', exclude: [AppService] }) 27 | // OR 28 | DebugModule.forRoot({ exclude: ['SampleService'] }) 29 | ``` 30 | 31 | ### Class 32 | 33 | You don't need to import `DebugModule` and `@Debug` when using it in class. It works as a separate decorator. \ 34 | Registering `@DebugLog` in a class applies to all methods in the class, so there is no need to register `@DebugLog` in a method. 35 | 36 | ```ts 37 | @Controller() 38 | @DebugLog('ClassContext') 39 | export class AppController { 40 | @Get() 41 | @DebugLog('MethodContext') 42 | public method() {} 43 | } 44 | ``` 45 | 46 | ## Logging 47 | 48 | See [Logger](./debug-log.decorator.ts#L15-L21) to edit log format. 49 | 50 | Example of log output of [step](./sample/sample.controller.ts#L16) method \ 51 | ![step](https://user-images.githubusercontent.com/1300172/179880495-dea3c467-0088-40a9-b44a-150b4166a081.png) 52 | 53 | Example of log output of [chain](./sample/sample.controller.ts#L24) method \ 54 | ![chain](https://user-images.githubusercontent.com/1300172/179880502-a84157f9-38dc-45d6-a2c9-34e3be85f0a6.png) 55 | -------------------------------------------------------------------------------- /src/debug/debug-log.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { performance } from 'node:perf_hooks'; 3 | // import { isAsyncFunction } from 'util/types'; // >= v15.3.0 4 | import { types } from 'node:util'; 5 | 6 | import type { Func } from './debug.interface'; 7 | 8 | const MethodLog = 9 | (context?: string): MethodDecorator => 10 | (target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor): void => { 11 | const originalMethod: unknown = descriptor.value; 12 | if (typeof originalMethod !== 'function') { 13 | return; 14 | } 15 | 16 | const log = function (time: number, args: unknown[]): void { 17 | const ownKey = typeof target === 'function' ? target.name : ''; 18 | const name = context ? `${ownKey}.${String(propertyKey)}` : String(propertyKey); 19 | const params = args.length > 0 ? `(${args.toString()})` : ''; 20 | 21 | Logger.debug(`${name}${params} +${time.toFixed(2)}ms`, context ?? ownKey); 22 | }; 23 | 24 | if (types.isAsyncFunction(originalMethod)) { 25 | descriptor.value = async function (...args: unknown[]): Promise { 26 | const start = performance.now(); 27 | const result: unknown = await originalMethod.apply(this, args); 28 | const end = performance.now(); 29 | 30 | log(end - start, args); 31 | // or Use result to add response log 32 | return result; 33 | }; 34 | } else { 35 | descriptor.value = function (...args: unknown[]): unknown { 36 | const start = performance.now(); 37 | const result: unknown = originalMethod.apply(this, args); 38 | const end = performance.now(); 39 | 40 | log(end - start, args); 41 | return result; 42 | }; 43 | } 44 | }; 45 | 46 | /** 47 | * https://stackoverflow.com/questions/47621364 48 | * https://github.com/Papooch/decorate-all 49 | */ 50 | const ClassLog = 51 | (context?: string): ClassDecorator => 52 | (target: Func): void => { 53 | const descriptors = Object.getOwnPropertyDescriptors(target.prototype); 54 | 55 | for (const [propertyKey, descriptor] of Object.entries(descriptors)) { 56 | const originalMethod: unknown = descriptor.value; 57 | if (typeof originalMethod !== 'function' || propertyKey === 'constructor') { 58 | continue; 59 | } 60 | 61 | MethodLog(context)(target, propertyKey, descriptor); 62 | 63 | if (originalMethod !== descriptor.value) { 64 | const metadataKeys = Reflect.getMetadataKeys(originalMethod); 65 | for (const key of metadataKeys) { 66 | const value: unknown = Reflect.getMetadata(key, originalMethod); 67 | Reflect.defineMetadata(key, value, descriptor.value); 68 | } 69 | } 70 | 71 | Object.defineProperty(target.prototype, propertyKey, descriptor); 72 | } 73 | }; 74 | 75 | export const DebugLog = 76 | (context?: string): ClassDecorator & MethodDecorator => 77 | (target: object, propertyKey?: string | symbol, descriptor?: PropertyDescriptor): void => { 78 | if (!descriptor) { 79 | ClassLog(context)(target); 80 | } else if (propertyKey) { 81 | MethodLog(context)(target, propertyKey, descriptor); 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /src/debug/debug.constant.ts: -------------------------------------------------------------------------------- 1 | export const DEBUG_METADATA = 'debug'; 2 | -------------------------------------------------------------------------------- /src/debug/debug.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata, type CustomDecorator } from '@nestjs/common'; 2 | 3 | import { DEBUG_METADATA } from './debug.constant'; 4 | import type { DebugOptions } from './debug.interface'; 5 | 6 | export const Debug = (options?: string | DebugOptions): CustomDecorator => 7 | SetMetadata(DEBUG_METADATA, { context: options, ...(typeof options === 'object' && options) }); 8 | -------------------------------------------------------------------------------- /src/debug/debug.explorer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import { Inject, Injectable, Type } from '@nestjs/common'; 3 | import { MODULE_METADATA } from '@nestjs/common/constants'; 4 | import { DiscoveryService, Reflector } from '@nestjs/core'; 5 | import type { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; 6 | 7 | import { DebugLog } from './debug-log.decorator'; 8 | import { DEBUG_METADATA } from './debug.constant'; 9 | import type { DebugModuleOptions, DebugOptions, Metatype } from './debug.interface'; 10 | import { MODULE_OPTIONS_TOKEN } from './debug.module-definition'; 11 | 12 | @Injectable() 13 | export class DebugExplorer { 14 | private exclude = new Set(['Logger', 'ConfigService']); 15 | 16 | constructor( 17 | @Inject(MODULE_OPTIONS_TOKEN) private options: DebugModuleOptions, 18 | private discoveryService: DiscoveryService, 19 | private reflector: Reflector, 20 | ) { 21 | this.addExcludeOption(); 22 | 23 | const instanceWrappers: InstanceWrapper[] = [...this.discoveryService.getControllers(), ...this.discoveryService.getProviders()]; 24 | 25 | for (const wrapper of instanceWrappers.filter((wrap: InstanceWrapper) => !wrap.isNotMetatype)) { 26 | const { instance, metatype } = wrapper; 27 | if (!instance || !Object.getPrototypeOf(instance) || !metatype) { 28 | continue; 29 | } 30 | 31 | const metadata = this.reflector.get(DEBUG_METADATA, metatype); 32 | if (!metadata) { 33 | continue; 34 | } 35 | 36 | this.applyDecorator(metatype, metadata); 37 | } 38 | } 39 | 40 | private addExcludeOption(): void { 41 | if (!Array.isArray(this.options.exclude)) { 42 | return; 43 | } 44 | 45 | for (const type of this.options.exclude) { 46 | this.exclude.add(type); 47 | } 48 | } 49 | 50 | private applyDecorator(metatype: Metatype, metadata: DebugOptions): void { 51 | const instanceMetatypes: Type[] = [ 52 | ...(this.reflector.get(MODULE_METADATA.CONTROLLERS, metatype) ?? []), 53 | ...(this.reflector.get(MODULE_METADATA.PROVIDERS, metatype) ?? []), 54 | ]; 55 | 56 | for (const meta of instanceMetatypes) { 57 | if (typeof meta !== 'function' || this.exclude.has(meta.name) || metadata.exclude?.includes(meta)) { 58 | continue; 59 | } 60 | 61 | this.exclude.add(meta.name); 62 | DebugLog(metadata.context)(meta); 63 | } 64 | 65 | const imports = this.reflector.get('imports', metatype) ?? []; 66 | for (const module of imports) { 67 | this.applyDecorator(module, metadata); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/debug/debug.interface.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-function-type */ 2 | import type { Type } from '@nestjs/common'; 3 | 4 | export class ClassRef { 5 | [index: string]: Type; 6 | } 7 | 8 | export type Func = Function; 9 | export type Metatype = Type | Func; 10 | 11 | export interface DebugModuleOptions { 12 | exclude?: string[]; 13 | } 14 | 15 | export interface DebugOptions { 16 | context?: string; 17 | exclude?: Metatype[]; 18 | } 19 | -------------------------------------------------------------------------------- /src/debug/debug.module-definition.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurableModuleBuilder } from '@nestjs/common'; 2 | 3 | import type { DebugModuleOptions } from './debug.interface'; 4 | 5 | export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = new ConfigurableModuleBuilder() 6 | .setClassMethodName('forRoot') 7 | .build(); 8 | -------------------------------------------------------------------------------- /src/debug/debug.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule } from '@nestjs/common'; 2 | import { DiscoveryModule } from '@nestjs/core'; 3 | 4 | import { DebugExplorer } from './debug.explorer'; 5 | import { ConfigurableModuleClass, OPTIONS_TYPE } from './debug.module-definition'; 6 | 7 | @Module({}) 8 | export class DebugModule extends ConfigurableModuleClass { 9 | public static override forRoot(options: typeof OPTIONS_TYPE): DynamicModule { 10 | const module = super.forRoot(options); 11 | 12 | if (process.env.NODE_ENV !== 'production') { 13 | (module.imports ??= []).push(DiscoveryModule); 14 | (module.providers ??= []).push(DebugExplorer); 15 | } 16 | 17 | return module; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/debug/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sample'; 2 | export * from './debug-log.decorator'; 3 | export * from './debug.decorator'; 4 | export * from './debug.module'; 5 | -------------------------------------------------------------------------------- /src/debug/sample/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sample.module'; 2 | -------------------------------------------------------------------------------- /src/debug/sample/log-controller.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, Controller, type ControllerOptions } from '@nestjs/common'; 2 | 3 | import { DebugLog } from '../debug-log.decorator'; 4 | import type { Func } from '../debug.interface'; 5 | 6 | export const LogController = ({ context, ...options }: ControllerOptions & { context?: string }): Func => 7 | applyDecorators(DebugLog(context), Controller(options)); 8 | -------------------------------------------------------------------------------- /src/debug/sample/sample.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | import { SampleService } from './sample.service'; 4 | import { DebugLog } from '../debug-log.decorator'; 5 | 6 | /** 7 | * route /test/debug/* 8 | */ 9 | @Controller('debug') 10 | @DebugLog('ClassContext') 11 | export class SampleController { 12 | constructor(private sample: SampleService) {} 13 | 14 | @Get() // http://localhost:3000/test/debug 15 | @DebugLog('MethodContext') 16 | public step(): string { 17 | this.sample.stepOne('hello'); 18 | this.sample.stepTwo('world'); 19 | this.sample.stepThree(); 20 | return 'OK'; 21 | } 22 | 23 | @Get('chain') // http://localhost:3000/test/debug/chain 24 | public stepChain(): string { 25 | return this.sample.stepStart(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/debug/sample/sample.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { SampleController } from './sample.controller'; 4 | import { SampleService } from './sample.service'; 5 | import { SimpleLogController } from './simple-log.controller'; 6 | import { Debug } from '../debug.decorator'; 7 | import { DebugModule } from '../debug.module'; 8 | 9 | @Debug('ModuleContext') 10 | @Module({ 11 | imports: [DebugModule.forRoot({})], 12 | controllers: [SampleController, SimpleLogController], 13 | providers: [SampleService], 14 | }) 15 | export class SampleModule {} 16 | -------------------------------------------------------------------------------- /src/debug/sample/sample.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class SampleService { 5 | public stepOne(foo: string): string { 6 | return foo; 7 | } 8 | 9 | public stepTwo(bar: string): string { 10 | return bar; 11 | } 12 | 13 | public stepThree(): string { 14 | return 'step3'; 15 | } 16 | 17 | public stepStart(): string { 18 | return this.stepChainOne(); 19 | } 20 | 21 | public stepChainOne(): string { 22 | return this.stepChainTwo(); 23 | } 24 | 25 | public stepChainTwo(): string { 26 | return this.stepChainThree(); 27 | } 28 | 29 | public stepChainThree(): string { 30 | return this.stepChainFour(); 31 | } 32 | 33 | public stepChainFour(): string { 34 | return 'OK'; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/debug/sample/simple-log.controller.ts: -------------------------------------------------------------------------------- 1 | import { Get } from '@nestjs/common'; 2 | 3 | import { LogController } from './log-controller.decorator'; 4 | 5 | /** 6 | * route /test/debug/log/* 7 | */ 8 | @LogController({ context: 'Simple', path: 'debug/log' }) 9 | export class SimpleLogController { 10 | @Get() // http://localhost:3000/test/debug/log 11 | public log(): string { 12 | return 'OK'; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/entity/sampledb1/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sampletable1.entity'; 2 | -------------------------------------------------------------------------------- /src/entity/sampledb1/sampletable1.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity('sampletable1') 4 | export class Sampletable1 { 5 | @PrimaryGeneratedColumn({ type: 'int', unsigned: true, name: 'id' }) 6 | id!: number; 7 | 8 | @Column('varchar', { nullable: false, length: 255, name: 'title' }) 9 | title!: string; 10 | 11 | @Column('text', { nullable: true, name: 'content' }) 12 | content?: string; 13 | 14 | @Column('simple-array') 15 | tags?: string[]; 16 | 17 | @Column('timestamp', { nullable: false, default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' }) 18 | updated_at!: Date; 19 | 20 | @Column('timestamp', { nullable: false, default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) 21 | created_at!: Date; 22 | } 23 | -------------------------------------------------------------------------------- /src/entity/sampledb2/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sampletable2.entity'; 2 | -------------------------------------------------------------------------------- /src/entity/sampledb2/sampletable2.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity('sampletable2') 4 | export class Sampletable2 { 5 | @PrimaryGeneratedColumn({ type: 'int', unsigned: true, name: 'id' }) 6 | id!: number; 7 | 8 | @Column('varchar', { nullable: false, length: 255, name: 'title' }) 9 | title!: string; 10 | 11 | @Column('text', { nullable: true, name: 'content' }) 12 | content?: string; 13 | 14 | @Column('timestamp', { nullable: false, default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' }) 15 | updated_at!: Date; 16 | 17 | @Column('timestamp', { nullable: false, default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) 18 | created_at!: Date; 19 | } 20 | -------------------------------------------------------------------------------- /src/gql/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './simple.args'; 2 | export * from './simple.input'; 3 | -------------------------------------------------------------------------------- /src/gql/dto/simple.args.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType } from '@nestjs/graphql'; 2 | import { IsOptional, IsString } from 'class-validator'; 3 | 4 | @ArgsType() 5 | export class SimpleArgs { 6 | @IsString() 7 | public title!: string; 8 | 9 | @IsOptional() 10 | @IsString() 11 | public content?: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/gql/dto/simple.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType } from '@nestjs/graphql'; 2 | import { ArrayNotEmpty, IsOptional, IsString } from 'class-validator'; 3 | 4 | @InputType() 5 | export class SimpleInput { 6 | @IsString() 7 | public title!: string; 8 | 9 | @IsOptional() 10 | @IsString() 11 | public content?: string; 12 | 13 | @ArrayNotEmpty() 14 | @IsString({ each: true }) 15 | public tags!: string[]; 16 | } 17 | -------------------------------------------------------------------------------- /src/gql/gql.module.ts: -------------------------------------------------------------------------------- 1 | import { ApolloDriver } from '@nestjs/apollo'; 2 | import { Module } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { GqlModuleOptions, GraphQLModule } from '@nestjs/graphql'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | 7 | import { Sampletable1 } from '#entity/sampledb1'; 8 | import { SimpleService } from './providers'; 9 | import { SimpleResolver } from './resolvers'; 10 | import { DateScalar } from './scalars'; 11 | 12 | /** 13 | * https://docs.nestjs.com/graphql/quick-start 14 | */ 15 | @Module({ 16 | imports: [ 17 | GraphQLModule.forRootAsync({ 18 | driver: ApolloDriver, 19 | useFactory: (config: ConfigService) => ({ 20 | ...config.get('graphql'), 21 | }), 22 | inject: [ConfigService], 23 | }), 24 | TypeOrmModule.forFeature([Sampletable1]), 25 | ], 26 | providers: [SimpleResolver, SimpleService, DateScalar], 27 | }) 28 | export class GqlModule {} 29 | -------------------------------------------------------------------------------- /src/gql/index.ts: -------------------------------------------------------------------------------- 1 | export * from './gql.module'; 2 | -------------------------------------------------------------------------------- /src/gql/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './payload.model'; 2 | export * from './simple.model'; 3 | export * from './user.model'; 4 | -------------------------------------------------------------------------------- /src/gql/models/payload.model.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class Payload { 5 | public userId!: string; 6 | public username!: string; 7 | public roles: string[] = []; 8 | } 9 | -------------------------------------------------------------------------------- /src/gql/models/simple.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, Int, ObjectType } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class Simple { 5 | @Field(() => ID) 6 | public id!: number; 7 | 8 | @Field(() => Int) 9 | public score?: number; 10 | 11 | // If there is no type, the default is Float 12 | public rating?: number; 13 | 14 | public title!: string; 15 | public content?: string; 16 | public tags?: string[]; 17 | 18 | public createdAt?: Date; 19 | } 20 | -------------------------------------------------------------------------------- /src/gql/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class User { 5 | public id!: string; 6 | public name!: string; 7 | public email!: string; 8 | public roles: string[] = []; 9 | } 10 | -------------------------------------------------------------------------------- /src/gql/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './simple.service'; 2 | -------------------------------------------------------------------------------- /src/gql/providers/simple.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; 4 | import { Repository } from 'typeorm'; 5 | 6 | import { Sampletable1 } from '#entity/sampledb1'; 7 | import { UtilService } from '../../common'; 8 | import type { SimpleInput, SimpleArgs } from '../dto'; 9 | import { Simple } from '../models'; 10 | 11 | @Injectable() 12 | export class SimpleService { 13 | constructor( 14 | @InjectPinoLogger(SimpleService.name) private readonly logger: PinoLogger, 15 | @InjectRepository(Sampletable1) private sampletable: Repository, 16 | private util: UtilService, 17 | ) {} 18 | 19 | public async create(data: SimpleInput): Promise { 20 | this.logger.info('create'); 21 | 22 | return await this.sampletable.save(data); 23 | } 24 | 25 | public async read(id: number): Promise { 26 | this.logger.info('read'); 27 | 28 | const row = await this.sampletable.findOneBy({ id }); 29 | if (!row) { 30 | return null; 31 | } 32 | 33 | return Object.assign(new Simple(), row, { createdAt: row.created_at }); 34 | } 35 | 36 | public async find(args: SimpleArgs): Promise { 37 | this.logger.info('find'); 38 | 39 | const result = await this.sampletable.find( 40 | this.util.removeUndefined({ 41 | title: args.title, 42 | content: args.content, 43 | }), 44 | ); 45 | 46 | return result.map((row: Sampletable1) => Object.assign(new Simple(), row, { createdAt: row.created_at })); 47 | } 48 | 49 | public async remove(id: number): Promise { 50 | this.logger.info('remove'); 51 | 52 | const result = await this.sampletable.delete(id); 53 | 54 | return !!result.affected; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/gql/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './simple.resolver'; 2 | -------------------------------------------------------------------------------- /src/gql/resolvers/simple.resolver.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException, UseGuards } from '@nestjs/common'; 2 | import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql'; 3 | import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; 4 | 5 | import { JwtAuthGuard } from '../../auth'; 6 | import { ReqUser, Roles, RolesGuard } from '../../common'; 7 | import { SimpleInput, SimpleArgs } from '../dto'; 8 | import { Simple, Payload } from '../models'; 9 | import { SimpleService } from '../providers'; 10 | 11 | @Resolver(() => Simple) 12 | export class SimpleResolver { 13 | constructor( 14 | @InjectPinoLogger(SimpleService.name) private readonly logger: PinoLogger, 15 | private simpleService: SimpleService, 16 | ) {} 17 | 18 | @Query(() => Payload) 19 | @UseGuards(JwtAuthGuard, RolesGuard) 20 | @Roles('test') 21 | public user(@ReqUser() user: Payload): Payload { 22 | this.logger.info('user'); 23 | 24 | return user; 25 | } 26 | 27 | @Mutation(() => Simple) 28 | public async create(@Args('simpleData') simpleData: SimpleInput): Promise { 29 | this.logger.info('create'); 30 | 31 | return await this.simpleService.create(simpleData); 32 | } 33 | 34 | @Query(() => Simple) 35 | public async read(@Args('id', { type: () => ID }) id: number): Promise { 36 | this.logger.info('read'); 37 | 38 | const simple = await this.simpleService.read(id); 39 | if (!simple) { 40 | throw new NotFoundException('NotFoundData'); 41 | } 42 | 43 | return simple; 44 | } 45 | 46 | @Query(() => [Simple]) 47 | public async find(@Args() simpleArgs: SimpleArgs): Promise { 48 | this.logger.info('find'); 49 | 50 | return await this.simpleService.find(simpleArgs); 51 | } 52 | 53 | @Mutation(() => Boolean) 54 | public async remove(@Args('id', { type: () => ID }) id: number): Promise { 55 | this.logger.info('remove'); 56 | 57 | return await this.simpleService.remove(id); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/gql/scalars/date.scalar.ts: -------------------------------------------------------------------------------- 1 | import { CustomScalar, Scalar } from '@nestjs/graphql'; 2 | import { Kind, ValueNode } from 'graphql'; 3 | 4 | @Scalar('Date', () => Date) 5 | export class DateScalar implements CustomScalar { 6 | public description = 'Date custom scalar type'; 7 | 8 | public parseValue(value: unknown): Date { 9 | if (typeof value !== 'number') { 10 | throw new TypeError('DateScalar can only parse number values'); 11 | } 12 | 13 | return new Date(value); // from client 14 | } 15 | 16 | public serialize(value: unknown): string { 17 | if (!(value instanceof Date)) { 18 | throw new TypeError('DateScalar can only serialize Date values'); 19 | } 20 | 21 | return value.toISOString(); // to client 22 | } 23 | 24 | public parseLiteral(ast: ValueNode): Date | null { 25 | if (ast.kind === Kind.INT) { 26 | return new Date(ast.value); 27 | } 28 | 29 | return null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/gql/scalars/index.ts: -------------------------------------------------------------------------------- 1 | export * from './date.scalar'; 2 | -------------------------------------------------------------------------------- /src/repl.ts: -------------------------------------------------------------------------------- 1 | import { Logger as NestLogger } from '@nestjs/common'; 2 | import { repl } from '@nestjs/core'; 3 | 4 | import { AppModule } from './app.module'; 5 | 6 | /** 7 | * https://docs.nestjs.com/recipes/repl 8 | */ 9 | async function bootstrap(): Promise { 10 | await repl(AppModule); 11 | } 12 | 13 | void (async (): Promise => { 14 | try { 15 | await bootstrap(); 16 | NestLogger.log('REPLServer', 'Bootstrap'); 17 | } catch (error) { 18 | NestLogger.error(error, 'Bootstrap'); 19 | } 20 | })(); 21 | -------------------------------------------------------------------------------- /src/sample/controllers/crud.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, type TestingModule } from '@nestjs/testing'; 2 | import { mockDeep, type DeepMockProxy } from 'jest-mock-extended'; 3 | 4 | import type { Sampletable1 } from '#entity/sampledb1'; 5 | import { CrudController } from './crud.controller'; 6 | import { CrudService } from '../providers'; 7 | 8 | let moduleRef: TestingModule | undefined; 9 | let controller: CrudController; 10 | let service: DeepMockProxy; 11 | let mockValue: Sampletable1; 12 | 13 | beforeAll(async () => { 14 | moduleRef = await Test.createTestingModule({ 15 | controllers: [CrudController], 16 | providers: [CrudService], 17 | }) 18 | .overrideProvider(CrudService) 19 | .useValue(mockDeep()) 20 | .compile(); 21 | 22 | controller = moduleRef.get(CrudController); 23 | service = moduleRef.get(CrudService); 24 | }); 25 | 26 | test('create', async () => { 27 | const data = { title: 'FooBar', content: 'Hello World', tags: ['new'] }; 28 | mockValue = { id: 1, ...data, created_at: new Date(), updated_at: new Date() }; 29 | service.create.mockResolvedValue(mockValue); 30 | 31 | const result = await controller.create(data); 32 | expect(result).toHaveProperty('id', 1); 33 | }); 34 | 35 | test('read', async () => { 36 | service.read.mockResolvedValue(mockValue); 37 | 38 | const result = await controller.read(1); 39 | expect(result).toEqual(mockValue); 40 | }); 41 | 42 | test('update', async () => { 43 | mockValue.title = 'Blahblahblah'; 44 | mockValue.tags = ['update']; 45 | service.update.mockResolvedValue({ raw: '-', affected: 1, generatedMaps: [] }); 46 | 47 | const result = await controller.update(1, { content: mockValue.title, tags: mockValue.tags }); 48 | expect(result).toHaveProperty('success', true); 49 | }); 50 | 51 | test('delete', async () => { 52 | service.remove.mockResolvedValue({ raw: '-', affected: 1 }); 53 | 54 | const result = await controller.remove(1); 55 | expect(result).toHaveProperty('success', true); 56 | }); 57 | 58 | afterAll(async () => { 59 | await moduleRef?.close(); 60 | }); 61 | -------------------------------------------------------------------------------- /src/sample/controllers/crud.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Param, 6 | Post, 7 | Put, 8 | Delete, 9 | NotFoundException, 10 | InternalServerErrorException, 11 | ParseIntPipe, 12 | } from '@nestjs/common'; 13 | 14 | import type { Sampletable1 } from '#entity/sampledb1'; 15 | import { CreateDto, UpdateDto } from '../dto'; 16 | import { CrudService } from '../providers'; 17 | 18 | /** 19 | * route /test/crud/* 20 | */ 21 | @Controller('crud') 22 | export class CrudController { 23 | constructor(private crud: CrudService) {} 24 | 25 | @Get(':id') // GET http://localhost:3000/test/crud/:id 26 | public async read(@Param('id', ParseIntPipe) id: number): Promise { 27 | const result = await this.crud.read(id); 28 | if (!result) { 29 | throw new NotFoundException('NotFoundData'); 30 | } 31 | 32 | return result; 33 | } 34 | 35 | @Post() // POST http://localhost:3000/test/crud 36 | public async create(@Body() body: CreateDto): Promise<{ id: number }> { 37 | const result = await this.crud.create(body); 38 | if (!result.id) { 39 | throw new InternalServerErrorException('NotCreatedData'); 40 | } 41 | 42 | return { id: result.id }; 43 | } 44 | 45 | @Put(':id') // PUT http://localhost:3000/test/crud/:id 46 | public async update(@Param('id', ParseIntPipe) id: number, @Body() body: UpdateDto): Promise<{ success: boolean }> { 47 | const result = await this.crud.update(id, body); 48 | 49 | return { success: !!result.affected }; 50 | } 51 | 52 | @Delete(':id') // DELETE http://localhost:3000/test/crud/:id 53 | public async remove(@Param('id', ParseIntPipe) id: number): Promise<{ success: boolean }> { 54 | const result = await this.crud.remove(id); 55 | 56 | return { success: !!result.affected }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/sample/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './crud.controller'; 2 | export * from './sample.controller'; 3 | -------------------------------------------------------------------------------- /src/sample/controllers/sample.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, type TestingModule } from '@nestjs/testing'; 2 | import { mockDeep } from 'jest-mock-extended'; 3 | import { getLoggerToken, type PinoLogger } from 'nestjs-pino'; 4 | 5 | import { SampleController } from './sample.controller'; 6 | import { ConfigService } from '../../common'; 7 | 8 | let moduleRef: TestingModule | undefined; 9 | let controller: SampleController; 10 | 11 | const config = { 12 | hello: 'world', 13 | foo: 'bar', 14 | }; 15 | 16 | beforeAll(async () => { 17 | moduleRef = await Test.createTestingModule({ 18 | controllers: [SampleController], 19 | providers: [ 20 | { 21 | provide: getLoggerToken(SampleController.name), 22 | useValue: mockDeep(), 23 | }, 24 | ConfigService, 25 | ], 26 | }) 27 | .overrideProvider(ConfigService) 28 | .useValue({ 29 | get: jest.fn((key: keyof typeof config) => config[key]), 30 | }) 31 | .useMocker(mockDeep) 32 | .compile(); 33 | 34 | controller = moduleRef.get(SampleController); 35 | }); 36 | 37 | test('sample', () => { 38 | expect(controller.sample()).toEqual(config); 39 | }); 40 | 41 | afterAll(async () => { 42 | await moduleRef?.close(); 43 | }); 44 | -------------------------------------------------------------------------------- /src/sample/controllers/sample.controller.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Body, Controller, Get, Param, ParseIntPipe, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; 2 | import type { Request, Response } from 'express'; 3 | import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; 4 | 5 | import type { Sampletable1 } from '#entity/sampledb1'; 6 | import { Roles, RolesGuard, ConfigService } from '../../common'; 7 | import { FoobarService } from '../../shared/foobar'; 8 | import { SampleDto } from '../dto'; 9 | import { DatabaseService } from '../providers'; 10 | 11 | /** 12 | * route /test/sample/* 13 | */ 14 | @UseGuards(RolesGuard) 15 | @Controller('sample') 16 | export class SampleController { 17 | constructor( 18 | @InjectPinoLogger(SampleController.name) private readonly logger: PinoLogger, 19 | private config: ConfigService, 20 | private dbquery: DatabaseService, 21 | private foobarService: FoobarService, 22 | ) {} 23 | 24 | @Get() // http://localhost:3000/test/sample 25 | public sample(): Record { 26 | this.logger.info('this is sample'); 27 | 28 | return { 29 | hello: this.config.get('hello'), 30 | foo: this.config.get('foo'), 31 | }; 32 | } 33 | 34 | @Get('hello') // http://localhost:3000/test/sample/hello 35 | public hello(@Req() req: Request, @Res() res: Response): void { 36 | res.json({ 37 | message: req.originalUrl, 38 | }); 39 | } 40 | 41 | @Get('hello/query') // http://localhost:3000/test/sample/hello/query?name=anything 42 | public helloQuery(@Query('name') name: string): string { 43 | if (!name) { 44 | throw new BadRequestException('InvalidParameter'); 45 | } 46 | 47 | return `helloQuery: ${name}`; 48 | } 49 | 50 | @Get('hello/param/:name') // http://localhost:3000/test/sample/hello/param/anything 51 | public helloParam(@Param('name') name: string): string { 52 | return `helloParam: ${name}`; 53 | } 54 | 55 | @Get('hello/number/:foo') // http://localhost:3000/test/sample/hello/number/123?bar=456&blah=789 56 | public helloNumber(@Param('foo') foo: number, @Query('bar') bar: string, @Query('blah', ParseIntPipe) blah: number): AnyObject { 57 | return { foo, bar, blah }; 58 | } 59 | 60 | @Post('hello/body') // http://localhost:3000/test/sample/hello/body 61 | public helloBody(@Body() param: SampleDto): string { 62 | return `helloBody: ${JSON.stringify(param)}`; 63 | } 64 | 65 | @Get('database') 66 | public async database(): Promise { 67 | // this.dbquery.sample2(); 68 | // this.dbquery.sample3(); 69 | return await this.dbquery.sample1(); 70 | } 71 | 72 | @Get('foobars') 73 | public async foobars(): Promise { 74 | return await this.foobarService.getFoobars(); 75 | } 76 | 77 | @Roles('admin') 78 | @Get('admin') // http://localhost:3000/test/sample/admin 79 | public admin(): string { 80 | return 'Need admin role'; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/sample/dto/create.dto.ts: -------------------------------------------------------------------------------- 1 | import { ArrayNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | 3 | import type { Sampletable1 } from '#entity/sampledb1'; 4 | 5 | export class CreateDto implements Omit { 6 | @IsString() 7 | public title!: string; 8 | 9 | @IsOptional() 10 | @IsString() 11 | public content?: string; 12 | 13 | @ArrayNotEmpty() 14 | @IsString({ each: true }) 15 | public tags!: string[]; 16 | } 17 | -------------------------------------------------------------------------------- /src/sample/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create.dto'; 2 | export * from './sample.dto'; 3 | export * from './update.dto'; 4 | -------------------------------------------------------------------------------- /src/sample/dto/sample.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform, TransformFnParams } from 'class-transformer'; 2 | import { IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; 3 | 4 | import { DateService } from '../providers'; 5 | 6 | /** 7 | * https://github.com/typestack/class-validator#validation-decorators 8 | * https://docs.nestjs.com/techniques/serialization 9 | */ 10 | export class SampleDto { 11 | @IsNumber() 12 | public id!: number; 13 | 14 | @IsString() 15 | public title!: string; 16 | 17 | @IsOptional() 18 | @IsString() 19 | public content?: string; // optional value 20 | 21 | @IsDateString() // ISO 8601 22 | public date: string = new Date().toISOString(); // default value 23 | 24 | @IsString() // Change date format 25 | @Transform(({ value }: TransformFnParams) => DateService.format(String(value))) 26 | public datetime!: string; 27 | 28 | @IsNotEmpty() 29 | public something!: string; 30 | 31 | @IsNumber() 32 | public page = 1; 33 | } 34 | -------------------------------------------------------------------------------- /src/sample/dto/update.dto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://github.com/nestjs/mapped-types 3 | * https://docs.nestjs.com/openapi/mapped-types for swagger 4 | */ 5 | import { OmitType } from '@nestjs/mapped-types'; 6 | // import { OmitType } from '@nestjs/swagger'; 7 | 8 | import { CreateDto } from './create.dto'; 9 | 10 | /** 11 | * Mapped Types: PartialType, PickType, OmitType, IntersectionType 12 | */ 13 | export class UpdateDto extends OmitType(CreateDto, ['title']) {} 14 | -------------------------------------------------------------------------------- /src/sample/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sample.module'; 2 | -------------------------------------------------------------------------------- /src/sample/providers/crud.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, type TestingModule } from '@nestjs/testing'; 2 | import { getRepositoryToken } from '@nestjs/typeorm'; 3 | import { mockDeep, type DeepMockProxy } from 'jest-mock-extended'; 4 | import type { Repository } from 'typeorm'; 5 | 6 | import { Sampletable1 } from '#entity/sampledb1'; 7 | import { CrudService } from './crud.service'; 8 | 9 | let moduleRef: TestingModule | undefined; 10 | let repository: DeepMockProxy>; 11 | let service: CrudService; 12 | let mockValue: Sampletable1; 13 | 14 | beforeAll(async () => { 15 | moduleRef = await Test.createTestingModule({ 16 | providers: [ 17 | CrudService, 18 | { 19 | provide: getRepositoryToken(Sampletable1), 20 | useValue: mockDeep>(), 21 | }, 22 | ], 23 | }).compile(); 24 | 25 | repository = moduleRef.get(getRepositoryToken(Sampletable1)); 26 | service = moduleRef.get(CrudService); 27 | }); 28 | 29 | test('create', async () => { 30 | const data = { title: 'FooBar', content: 'Hello World', tags: ['new'] }; 31 | mockValue = { id: 1, ...data, created_at: new Date(), updated_at: new Date() }; 32 | repository.save.mockResolvedValue(mockValue); 33 | 34 | const result = await service.create(data); 35 | expect(result).toHaveProperty('id', 1); 36 | }); 37 | 38 | test('read', async () => { 39 | repository.findOneBy.mockResolvedValue(mockValue); 40 | 41 | const result = await service.read(1); 42 | expect(result).toEqual(mockValue); 43 | }); 44 | 45 | test('update', async () => { 46 | mockValue.title = 'Blahblahblah'; 47 | mockValue.tags = ['update']; 48 | repository.update.mockResolvedValue({ raw: '-', affected: 1, generatedMaps: [] }); 49 | 50 | const result = await service.update(1, { title: mockValue.title, tags: mockValue.tags }); 51 | expect(result).toHaveProperty('affected', 1); 52 | }); 53 | 54 | test('delete', async () => { 55 | repository.delete.mockResolvedValue({ raw: '-', affected: 1 }); 56 | 57 | const result = await service.remove(1); 58 | expect(result).toHaveProperty('affected', 1); 59 | }); 60 | 61 | afterAll(async () => { 62 | await moduleRef?.close(); 63 | }); 64 | -------------------------------------------------------------------------------- /src/sample/providers/crud.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository, UpdateResult, DeleteResult } from 'typeorm'; 4 | 5 | import { Sampletable1 } from '#entity/sampledb1'; 6 | 7 | @Injectable() 8 | export class CrudService { 9 | constructor( 10 | @InjectRepository(Sampletable1) 11 | private table: Repository, 12 | ) {} 13 | 14 | public async create(data: Partial): Promise { 15 | return await this.table.save(data); 16 | } 17 | 18 | public async read(id: number): Promise { 19 | return await this.table.findOneBy({ id }); 20 | } 21 | 22 | public async update(id: number, data: Partial): Promise { 23 | return await this.table.update(id, data); 24 | } 25 | 26 | public async remove(id: number): Promise { 27 | return await this.table.delete(id); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/sample/providers/database.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm'; 3 | import { EntityManager, Repository } from 'typeorm'; 4 | 5 | import { Sampletable1 } from '#entity/sampledb1'; 6 | import { Sampletable2 } from '#entity/sampledb2'; 7 | 8 | /** 9 | * Database Query Execution Example 10 | */ 11 | @Injectable() 12 | export class DatabaseService { 13 | private tablerepo: Repository; 14 | 15 | constructor( 16 | /** 17 | * Sample1 18 | * https://typeorm.io/#/working-with-repository 19 | * https://typeorm.io/#/repository-api 20 | * Need TypeOrmModule.forFeature([]) imports 21 | */ 22 | @InjectRepository(Sampletable1) 23 | private sampletable1: Repository, 24 | 25 | /** 26 | * Sample2 27 | * https://typeorm.io/#/working-with-entity-manager 28 | * https://typeorm.io/#/entity-manager-api 29 | */ 30 | @InjectEntityManager() 31 | private manager: EntityManager, 32 | ) { 33 | /** 34 | * Sample3 35 | * https://typeorm.io/#/entity-manager-api - getRepository 36 | */ 37 | this.tablerepo = this.manager.getRepository(Sampletable1); 38 | } 39 | 40 | /** 41 | * https://typeorm.io/#/find-options 42 | */ 43 | public async sample1(): Promise { 44 | // Repository 45 | return await this.sampletable1.find(); 46 | } 47 | 48 | public async sample2(): Promise { 49 | // EntityManager 50 | return await this.manager.find(Sampletable1); 51 | } 52 | 53 | public async sample3(): Promise { 54 | // EntityManagerRepository 55 | return await this.tablerepo.find(); 56 | } 57 | 58 | /** 59 | * https://typeorm.io/#/select-query-builder 60 | */ 61 | public async joinQuery(): Promise { 62 | await this.sampletable1 63 | .createQueryBuilder('tb1') 64 | .innerJoin('sampletable2', 'tb2', 'tb2.id = tb1.id') // inner or left 65 | .select(['tb1', 'tb2.title']) 66 | .where('tb1.id = :id', { id: 123 }) 67 | .getRawOne(); // getOne, getMany, getRawMany ... 68 | 69 | await this.sampletable1.createQueryBuilder('tb1').innerJoinAndSelect('sampletable2', 'tb2', 'tb2.id = tb1.id').getOne(); 70 | 71 | await this.sampletable1.createQueryBuilder('tb1').leftJoinAndSelect(Sampletable2, 'tb2', 'tb2.id = tb1.id').getRawMany(); 72 | 73 | await this.sampletable1.createQueryBuilder('tb1').leftJoinAndMapOne('tb1.tb2row', 'sampletable2', 'tb2', 'tb2.id = tb1.id').getOne(); 74 | 75 | await this.sampletable1.createQueryBuilder('tb1').leftJoinAndMapMany('tb1.tb2row', Sampletable2, 'tb2', 'tb2.id = tb1.id').getMany(); 76 | 77 | return true; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/sample/providers/date.service.ts: -------------------------------------------------------------------------------- 1 | export class DateService { 2 | public static format(value: string): string { 3 | return new Date(value).toLocaleString(); 4 | } 5 | 6 | /* // Using dayjs 7 | public static FORMAT(value: ConfigType) { 8 | return dayjs(value).format('YYYY-MM-DD HH:mm:ss'); 9 | } 10 | */ 11 | } 12 | -------------------------------------------------------------------------------- /src/sample/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './crud.service'; 2 | export * from './database.service'; 3 | export * from './date.service'; 4 | -------------------------------------------------------------------------------- /src/sample/sample.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Sampletable1 } from '#entity/sampledb1'; 5 | import { Sampletable2 } from '#entity/sampledb2'; 6 | import * as controllers from './controllers'; 7 | import * as providers from './providers'; 8 | import { FoobarModule } from '../shared/foobar'; 9 | 10 | @Module({ 11 | imports: [ 12 | TypeOrmModule.forFeature([ 13 | // ...Object.values(tables) 14 | Sampletable1, 15 | Sampletable2, 16 | ]), 17 | FoobarModule, // Shared Module 18 | ], 19 | controllers: Object.values(controllers), 20 | providers: Object.values(providers), 21 | }) 22 | export class SampleModule {} 23 | -------------------------------------------------------------------------------- /src/shared/README.md: -------------------------------------------------------------------------------- 1 | # Shared 2 | 3 | Shared Nest Modules 4 | 5 | ## Note 6 | 7 | - The module common to all modules is [CommonModule](../common) 8 | 9 | ## Structure 10 | 11 | ```js 12 | +-- shared 13 | | +-- foobar 14 | | | +-- foobar.constant.ts 15 | | | +-- foobar.interface.ts 16 | | | +-- foobar.service.ts 17 | | | +-- foobar.module.ts 18 | | | +-- foobar.*.ts 19 | | | +-- index.ts 20 | | +--- other ... 21 | ``` 22 | -------------------------------------------------------------------------------- /src/shared/foobar/foobar.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Sampletable1 } from '#entity/sampledb1'; 5 | import { FoobarService } from './foobar.service'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Sampletable1])], 9 | providers: [FoobarService], 10 | exports: [FoobarService], 11 | }) 12 | export class FoobarModule {} 13 | -------------------------------------------------------------------------------- /src/shared/foobar/foobar.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { Sampletable1 } from '#entity/sampledb1'; 6 | 7 | @Injectable() 8 | export class FoobarService { 9 | constructor( 10 | @InjectRepository(Sampletable1) 11 | private sampletable1: Repository, 12 | ) {} 13 | 14 | public async getFoobars(): Promise { 15 | return await this.sampletable1.find({ take: 10 }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/foobar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './foobar.module'; 2 | export * from './foobar.service'; 3 | -------------------------------------------------------------------------------- /src/shared/user/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './user.interface'; 2 | export * from './user.module'; 3 | export * from './user.service'; 4 | -------------------------------------------------------------------------------- /src/shared/user/user.interface.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | name: string; 4 | email: string; 5 | roles: string[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { UserService } from './user.service'; 4 | 5 | @Module({ 6 | providers: [UserService], 7 | exports: [UserService], 8 | }) 9 | export class UserModule {} 10 | -------------------------------------------------------------------------------- /src/shared/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import type { User } from './user.interface'; 4 | 5 | @Injectable() 6 | export class UserService { 7 | public async fetch(username: string): Promise { 8 | return await Promise.resolve({ 9 | id: 'test', 10 | // eslint-disable-next-line sonarjs/no-hardcoded-passwords 11 | password: 'crypto', 12 | name: username, 13 | email: `${username}@test.com`, 14 | roles: ['test'], // ['admin', 'etc', ...] 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/swagger.ts: -------------------------------------------------------------------------------- 1 | import { Logger as NestLogger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 4 | 5 | import { AppModule } from './app.module'; 6 | 7 | /** 8 | * https://docs.nestjs.com/recipes/swagger 9 | */ 10 | async function bootstrap(): Promise { 11 | const app = await NestFactory.create(AppModule); 12 | 13 | const options = new DocumentBuilder() 14 | .setTitle('OpenAPI Documentation') 15 | .setDescription('The sample API description') 16 | .setVersion('1.0') 17 | .addBearerAuth() 18 | .build(); 19 | const document = SwaggerModule.createDocument(app, options); 20 | SwaggerModule.setup('api', app, document); 21 | 22 | await app.listen(process.env.PORT || 8000); 23 | 24 | return await app.getUrl(); 25 | } 26 | 27 | void (async (): Promise => { 28 | try { 29 | const url = await bootstrap(); 30 | NestLogger.log(url, 'Bootstrap'); 31 | } catch (error) { 32 | NestLogger.error(error, 'Bootstrap'); 33 | } 34 | })(); 35 | -------------------------------------------------------------------------------- /test/e2e/crud.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import type { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { Test } from '@nestjs/testing'; 4 | import supertest from 'supertest'; 5 | 6 | import { AppModule } from '../../src/app.module'; 7 | 8 | let app: NestExpressApplication | undefined; 9 | let request: supertest.Agent; 10 | let idx: number; 11 | 12 | beforeAll(async () => { 13 | const moduleRef = await Test.createTestingModule({ 14 | imports: [AppModule], 15 | }).compile(); 16 | 17 | app = moduleRef.createNestApplication(); 18 | await app.init(); 19 | 20 | request = supertest(app.getHttpServer()); 21 | }); 22 | 23 | test('POST: /test/crud', async () => { 24 | const { status, body } = await request.post('/test/crud').send({ title: 'FooBar', content: 'Hello World', tags: ['new'] }); 25 | 26 | expect([200, 201]).toContain(status); 27 | expect(body).toHaveProperty('id'); 28 | 29 | idx = body.id; 30 | }); 31 | 32 | test('GET: /test/crud/:idx', async () => { 33 | const { body } = await request.get(`/test/crud/${idx}`).expect(200); 34 | 35 | expect(body).toHaveProperty('title', 'FooBar'); 36 | }); 37 | 38 | test('PUT: /test/crud/:idx', async () => { 39 | const { body } = await request 40 | .put(`/test/crud/${idx}`) 41 | .send({ title: 'Blahblahblah', tags: ['update'] }) 42 | .expect(200); 43 | 44 | expect(body).toHaveProperty('success', true); 45 | }); 46 | 47 | test('DELETE: /test/crud/:idx', async () => { 48 | const { body } = await request.delete(`/test/crud/${idx}`).expect(200); 49 | 50 | expect(body).toHaveProperty('success', true); 51 | }); 52 | 53 | afterAll(async () => { 54 | await app?.close(); 55 | }); 56 | -------------------------------------------------------------------------------- /test/e2e/gql.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment, sonarjs/no-hardcoded-credentials */ 2 | import type { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { Test } from '@nestjs/testing'; 4 | import supertest from 'supertest'; 5 | 6 | import { AppModule } from '../../src/app.module'; 7 | 8 | // https://www.apollographql.com/docs/apollo-server/testing/testing/ 9 | // As another alternative, can use apollo-server-testing instead of supertest 10 | 11 | const gql = String.raw; // for highlighting 12 | let app: NestExpressApplication | undefined; 13 | let request: supertest.Agent; 14 | let idx: number; 15 | 16 | beforeAll(async () => { 17 | const moduleRef = await Test.createTestingModule({ 18 | imports: [AppModule], 19 | }).compile(); 20 | 21 | app = moduleRef.createNestApplication(); 22 | await app.init(); 23 | 24 | request = supertest(app.getHttpServer()); 25 | }); 26 | 27 | test('User', async () => { 28 | // eslint-disable-next-line sonarjs/no-hardcoded-passwords 29 | const { status, body: login } = await request.post('/jwt/login').send({ username: 'foobar', password: 'crypto' }); 30 | 31 | expect([200, 201]).toContain(status); 32 | expect(login).toHaveProperty('access_token'); 33 | 34 | const { body } = await request 35 | .post('/graphql') 36 | .set('Authorization', `Bearer ${login.access_token}`) 37 | .send({ 38 | query: gql` 39 | query Payload { 40 | user { 41 | username 42 | } 43 | } 44 | `, 45 | }) 46 | .expect(200); 47 | 48 | expect(body).toHaveProperty('data.user.username', 'foobar'); 49 | }); 50 | 51 | test('Write', async () => { 52 | const { body } = await request 53 | .post('/graphql') 54 | .send({ 55 | query: gql` 56 | mutation Write($data: SimpleInput!) { 57 | create(simpleData: $data) { 58 | id 59 | title 60 | content 61 | tags 62 | } 63 | } 64 | `, 65 | variables: { 66 | data: { 67 | title: 'foo', 68 | content: 'bar', 69 | tags: 'test', 70 | }, 71 | }, 72 | }) 73 | .expect(200); 74 | 75 | expect(body).toHaveProperty('data.create.id'); 76 | 77 | idx = body.data.create.id; 78 | }); 79 | 80 | test('Read', async () => { 81 | const { body } = await request 82 | .post('/graphql') 83 | .send({ 84 | query: gql` 85 | query Read($id: ID!) { 86 | read(id: $id) { 87 | title 88 | createdAt 89 | } 90 | } 91 | `, 92 | variables: { 93 | id: idx, 94 | }, 95 | }) 96 | .expect(200); 97 | 98 | expect(body).toHaveProperty('data.read.title', 'foo'); 99 | }); 100 | 101 | test('Find', async () => { 102 | const { body } = await request 103 | .post('/graphql') 104 | .send({ 105 | query: gql` 106 | query Find($title: String!, $content: String) { 107 | find(title: $title, content: $content) { 108 | id 109 | title 110 | content 111 | tags 112 | createdAt 113 | } 114 | } 115 | `, 116 | variables: { 117 | title: 'foo', 118 | }, 119 | }) 120 | .expect(200); 121 | 122 | expect(body).toHaveProperty('data.find'); 123 | expect(body.data.find).toContainEqual( 124 | expect.objectContaining({ 125 | title: expect.any(String), 126 | createdAt: expect.any(String), 127 | }), 128 | ); 129 | }); 130 | 131 | test('Remove', async () => { 132 | const { body } = await request 133 | .post('/graphql') 134 | .send({ 135 | query: gql` 136 | mutation Remove($id: ID!) { 137 | remove(id: $id) 138 | } 139 | `, 140 | variables: { 141 | id: idx, 142 | }, 143 | }) 144 | .expect(200); 145 | 146 | expect(body).toHaveProperty('data.remove', true); 147 | }); 148 | 149 | afterAll(async () => { 150 | await app?.close(); 151 | }); 152 | -------------------------------------------------------------------------------- /test/e2e/jwt-auth.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment, new-cap, sonarjs/new-cap, sonarjs/no-hardcoded-credentials */ 2 | import type { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { Test } from '@nestjs/testing'; 4 | import supertest from 'supertest'; 5 | 6 | import { AppModule } from '../../src/app.module'; 7 | 8 | let app: NestExpressApplication | undefined; 9 | let request: supertest.Agent; 10 | let accessToken: string; 11 | let refreshToken: string; 12 | 13 | beforeAll(async () => { 14 | const moduleRef = await Test.createTestingModule({ 15 | imports: [AppModule], 16 | }).compile(); 17 | 18 | app = moduleRef.createNestApplication(); 19 | await app.init(); 20 | 21 | request = new supertest.agent(app.getHttpServer()); 22 | }); 23 | 24 | test('POST: /jwt/login', async () => { 25 | // eslint-disable-next-line sonarjs/no-hardcoded-passwords 26 | const { status, body } = await request.post('/jwt/login').send({ username: 'foobar', password: 'crypto' }); 27 | 28 | expect([200, 201]).toContain(status); 29 | expect(body).toHaveProperty('access_token'); 30 | accessToken = body.access_token; 31 | refreshToken = body.refresh_token; 32 | }); 33 | 34 | test('GET: /jwt/check', async () => { 35 | const { body } = await request.get('/jwt/check').set('Authorization', `Bearer ${accessToken}`).expect(200); 36 | 37 | expect(body).toHaveProperty('username', 'foobar'); 38 | }); 39 | 40 | test('POST: /jwt/refresh', async () => { 41 | const { status, body } = await request 42 | .post('/jwt/refresh') 43 | .set('Authorization', `Bearer ${accessToken}`) 44 | .send({ refresh_token: refreshToken }); 45 | 46 | expect([200, 201]).toContain(status); 47 | expect(body).toEqual({ 48 | access_token: expect.any(String), 49 | refresh_token: expect.any(String), 50 | }); 51 | }); 52 | 53 | afterAll(async () => { 54 | await app?.close(); 55 | }); 56 | -------------------------------------------------------------------------------- /test/e2e/local-auth.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment, new-cap, sonarjs/new-cap, sonarjs/no-hardcoded-credentials */ 2 | import type { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { Test } from '@nestjs/testing'; 4 | import supertest from 'supertest'; 5 | 6 | import { middleware } from '../../src/app.middleware'; 7 | import { AppModule } from '../../src/app.module'; 8 | 9 | let app: NestExpressApplication | undefined; 10 | let request: supertest.Agent; 11 | 12 | beforeAll(async () => { 13 | const moduleRef = await Test.createTestingModule({ 14 | imports: [AppModule], 15 | }).compile(); 16 | 17 | app = moduleRef.createNestApplication(); 18 | // https://docs.nestjs.com/fundamentals/lifecycle-events 19 | // Error: passport.initialize() middleware not in use 20 | middleware(app); 21 | await app.init(); 22 | 23 | // https://github.com/visionmedia/supertest/issues/46#issuecomment-58534736 24 | request = new supertest.agent(app.getHttpServer()); 25 | }); 26 | 27 | test('POST: /login', async () => { 28 | // eslint-disable-next-line sonarjs/no-hardcoded-passwords 29 | const { status, body } = await request.post('/login').send({ username: 'foobar', password: 'crypto' }); 30 | 31 | expect([200, 201]).toContain(status); 32 | expect(body).toHaveProperty('username', 'foobar'); 33 | }); 34 | 35 | test('GET: /check', async () => { 36 | const { body } = await request.get('/check').expect(200); 37 | 38 | expect(body).toHaveProperty('userId', 'test'); 39 | }); 40 | 41 | test('GET: /logout', async () => { 42 | await request.get('/logout').expect(302); 43 | await request.get('/check').expect(403); 44 | }); 45 | 46 | afterAll(async () => { 47 | await app?.close(); 48 | }); 49 | -------------------------------------------------------------------------------- /test/jest.e2e.transformer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len, @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return */ 2 | // @ts-expect-error type is not supported 3 | import * as transformer from '@nestjs/graphql/plugin'; 4 | 5 | // https://docs.nestjs.com/graphql/cli-plugin#integration-with-ts-jest-e2e-tests 6 | export const version = 1; 7 | export const name = 'nestjs-graphql-transformer'; 8 | export const factory = (tsCompiler: any) => transformer.before({}, tsCompiler.program); 9 | -------------------------------------------------------------------------------- /test/jest.e2e.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-default-export */ 2 | import type { Config } from 'jest'; 3 | 4 | import config from '../jest.config'; 5 | 6 | // https://github.com/nestjs/graphql/issues/810#issuecomment-618308354 7 | const jestConfig: Config = { 8 | ...config, 9 | rootDir: '.', 10 | testMatch: ['**/e2e/**/*.+(spec|test).[tj]s?(x)'], 11 | testPathIgnorePatterns: ['/node_modules/'], 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | tsconfig: '/tsconfig.e2e.json', 17 | // https://kulshekhar.github.io/ts-jest/docs/getting-started/options/astTransformers 18 | astTransformers: { 19 | before: ['/jest.e2e.transformer.ts'], 20 | }, 21 | // https://kulshekhar.github.io/ts-jest/docs/getting-started/options/isolatedModules 22 | // isolatedModules: true, 23 | }, 24 | ], 25 | }, 26 | moduleNameMapper: { 27 | '#(.*)': '/../src/$1', 28 | }, 29 | }; 30 | 31 | export default jestConfig; 32 | -------------------------------------------------------------------------------- /test/test.spec.ts: -------------------------------------------------------------------------------- 1 | test('adds 1 + 2 to equal 3', () => { 2 | expect(1 + 2).toBe(3); 3 | }); 4 | -------------------------------------------------------------------------------- /test/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["../typings/global.d.ts", "../src/**/*", "e2e/**/*"], 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "exclude": ["**/*.spec.ts", "test/**/*", "bin/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | // https://docs.nestjs.com/cli/scripts#build 6 | // https://nodejs.org/api/packages.html#imports 7 | "#entity/*": ["./src/entity/*"], 8 | }, 9 | "target": "es2022", 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "incremental": true, 13 | "declaration": true, 14 | "newLine": "lf", 15 | "strict": true, 16 | "allowUnreachableCode": false, 17 | "allowUnusedLabels": false, 18 | "noEmitOnError": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "noImplicitOverride": true, 21 | "noImplicitReturns": true, 22 | "noPropertyAccessFromIndexSignature": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "removeComments": true, 26 | "sourceMap": true, 27 | "forceConsistentCasingInFileNames": true, 28 | "experimentalDecorators": true, 29 | // https://github.com/Microsoft/TypeScript/issues/2577 30 | "emitDecoratorMetadata": true, 31 | "esModuleInterop": true, 32 | "skipLibCheck": true 33 | }, 34 | "include": ["typings/global.d.ts", "src/**/*", "test/**/*", "bin/**/*"], 35 | "exclude": ["node_modules"] 36 | } 37 | -------------------------------------------------------------------------------- /typings/global.d.ts: -------------------------------------------------------------------------------- 1 | import type { Payload } from '../src/auth'; 2 | 3 | export declare global { 4 | type AnyObject = Record; 5 | 6 | namespace NodeJS { 7 | interface ProcessEnv { 8 | NODE_ENV: string; 9 | PORT: string; 10 | 11 | DB_TYPE: string; 12 | DB_HOST: string; 13 | DB_PORT: string; 14 | DB_USER: string; 15 | DB_PASSWORD: string; 16 | DB_NAME: string; 17 | 18 | JWT_SECRET: string; 19 | JWT_REFRESH_SECRET: string; 20 | } 21 | } 22 | 23 | namespace Express { 24 | interface Request { 25 | // customProps of pino-http 26 | customProps: object; 27 | } 28 | interface User extends Payload {} 29 | } 30 | } 31 | --------------------------------------------------------------------------------