├── src ├── config │ ├── index.ts │ ├── config.interface.ts │ ├── envs │ │ ├── test.ts │ │ ├── default.ts │ │ ├── development.ts │ │ └── production.ts │ ├── configuration.ts │ └── logger.config.ts ├── auth │ ├── guards │ │ ├── index.ts │ │ ├── jwt-auth.guard.ts │ │ └── local-auth.guard.ts │ ├── strategies │ │ ├── index.ts │ │ ├── jwt.strategy.ts │ │ └── local.strategy.ts │ ├── auth.interface.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ └── auth.service.ts ├── entities │ └── test │ │ ├── index.ts │ │ ├── category.entity.ts │ │ └── memo.entity.ts ├── user │ ├── user.interface.ts │ ├── index.ts │ ├── user.module.ts │ └── user.service.ts ├── common │ ├── index.ts │ ├── common.module.ts │ ├── logger-context.middleware.ts │ └── exceptions.filter.ts ├── health │ ├── health.module.ts │ └── health.controller.ts ├── sample │ ├── sample.dto.ts │ ├── sample.module.ts │ ├── sample.controller.ts │ ├── sample.service.ts │ └── sample.controller.spec.ts ├── app.middleware.ts ├── swagger.ts ├── app.ts ├── metadata.ts └── app.module.ts ├── tsconfig.build.json ├── .swcrc ├── test ├── test.spec.ts ├── vitest.e2e.ts └── e2e │ ├── auth.spec.ts │ └── sample.spec.ts ├── .env.sample ├── .gitattributes ├── vitest.config.ts ├── .prettierrc ├── public └── index.html ├── nest-cli.json ├── bin ├── generate-metadata.ts ├── synchronize.ts └── entities.ts ├── typings └── global.d.ts ├── tsconfig.json ├── .gitignore ├── README.md ├── package.json └── eslint.config.mjs /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './configuration.js'; 2 | export * from './logger.config.js'; 3 | -------------------------------------------------------------------------------- /src/auth/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt-auth.guard.js'; 2 | export * from './local-auth.guard.js'; 3 | -------------------------------------------------------------------------------- /src/auth/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt.strategy.js'; 2 | export * from './local.strategy.js'; 3 | -------------------------------------------------------------------------------- /src/entities/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './category.entity.js'; 2 | export * from './memo.entity.js'; 3 | -------------------------------------------------------------------------------- /src/user/user.interface.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | name: string; 4 | email: string; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.spec.ts", "test/**/*", "bin/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /src/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.interface.js'; 2 | export * from './user.service.js'; 3 | export * from './user.module.js'; 4 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://swc.rs/schema.json", 3 | "module": { 4 | "type": "es6", 5 | "resolveFully": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/test.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | 3 | test('adds 1 + 2 to equal 3', () => { 4 | expect(1 + 2).toBe(3); 5 | }); 6 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | DB_HOST = 127.0.0.1 2 | DB_USER = username 3 | DB_PASSWORD = password 4 | 5 | # https://jwt.io/introduction 6 | JWT_SECRET="secretKey" 7 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common.module.js'; 2 | export * from './exceptions.filter.js'; 3 | export * from './logger-context.middleware.js'; 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | 3 | @Global() 4 | @Module({ 5 | providers: [], 6 | exports: [], 7 | }) 8 | export class CommonModule {} 9 | -------------------------------------------------------------------------------- /src/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /src/auth/auth.interface.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../user/index.js'; 2 | 3 | export interface JwtPayload { 4 | sub: string; 5 | username: string; 6 | } 7 | 8 | export type Payload = Omit; 9 | -------------------------------------------------------------------------------- /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/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { UserService } from './user.service.js'; 4 | 5 | @Module({ 6 | providers: [UserService], 7 | exports: [UserService], 8 | }) 9 | export class UserModule {} 10 | -------------------------------------------------------------------------------- /src/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TerminusModule } from '@nestjs/terminus'; 3 | 4 | import { HealthController } from './health.controller.js'; 5 | 6 | @Module({ 7 | imports: [TerminusModule], 8 | controllers: [HealthController], 9 | }) 10 | export class HealthModule {} 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-default-export */ 2 | import swc from 'unplugin-swc'; 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | globals: true, 8 | root: './', 9 | testTimeout: 30_000, 10 | }, 11 | plugins: [swc.vite()], 12 | }); 13 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/config/config.interface.ts: -------------------------------------------------------------------------------- 1 | import type { config as base } from './envs/default.js'; 2 | import type { config as production } from './envs/production.js'; 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/sample/sample.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsString, ArrayNotEmpty } from 'class-validator'; 2 | 3 | export class SampleDto { 4 | @IsString() 5 | public title!: string; 6 | 7 | @IsOptional() 8 | @IsString() 9 | public content?: string; 10 | 11 | @IsOptional() 12 | @ArrayNotEmpty() 13 | public categories?: string[]; 14 | } 15 | -------------------------------------------------------------------------------- /test/vitest.e2e.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-default-export */ 2 | import { defineConfig, mergeConfig } from 'vitest/config'; 3 | 4 | import config from '../vitest.config.js'; 5 | 6 | export default mergeConfig( 7 | config, 8 | defineConfig({ 9 | test: { 10 | include: ['**/e2e/**/*.{spec,test}.ts'], 11 | }, 12 | }), 13 | true, 14 | ); 15 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | nestjs-project-performance 8 | 9 | 10 | 11 | 12 | Hello, world! 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/config/envs/test.ts: -------------------------------------------------------------------------------- 1 | import type { MySqlDriver } from '@mikro-orm/mysql'; 2 | import type { MikroOrmModuleOptions } from '@mikro-orm/nestjs'; 3 | 4 | export const config = { 5 | env: 'test', 6 | 7 | mikro: { 8 | debug: true, 9 | host: process.env['DB_HOST'] ?? '127.0.0.1', 10 | user: process.env['DB_USER'], 11 | password: process.env['DB_PASSWORD'], 12 | } satisfies MikroOrmModuleOptions, 13 | }; 14 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "entryFile": "app", 5 | "compilerOptions": { 6 | "tsConfigPath": "tsconfig.build.json", 7 | "deleteOutDir": true, 8 | "builder": "swc", 9 | "typeCheck": true, 10 | "plugins": [{ 11 | "name": "@nestjs/swagger", 12 | "options": { 13 | "introspectComments": true, 14 | "esmCompatible": true 15 | } 16 | }] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/sample/sample.module.ts: -------------------------------------------------------------------------------- 1 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 2 | import { Module } from '@nestjs/common'; 3 | 4 | import { SampleController } from './sample.controller.js'; 5 | import { SampleService } from './sample.service.js'; 6 | import { Category, Memo } from '../entities/test/index.js'; 7 | 8 | @Module({ 9 | imports: [MikroOrmModule.forFeature([Memo, Category])], 10 | controllers: [SampleController], 11 | providers: [SampleService], 12 | }) 13 | export class SampleModule {} 14 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import type { User } from './user.interface.js'; 4 | 5 | @Injectable() 6 | export class UserService { 7 | public async fetch(username: string): Promise<(User & { password: string }) | null> { 8 | return 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 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/common/logger-context.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, type NestMiddleware } from '@nestjs/common'; 2 | import { PinoLogger } from 'nestjs-pino'; 3 | import type { IncomingMessage, ServerResponse } from 'node:http'; 4 | 5 | @Injectable() 6 | export class LoggerContextMiddleware implements NestMiddleware { 7 | constructor(private readonly logger: PinoLogger) {} 8 | 9 | public use(req: IncomingMessage, _res: ServerResponse, next: () => void): void { 10 | req.customProps = {}; 11 | this.logger.assign(req.customProps); 12 | 13 | next(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /bin/generate-metadata.ts: -------------------------------------------------------------------------------- 1 | import { PluginMetadataGenerator } from '@nestjs/cli/lib/compiler/plugins/plugin-metadata-generator.js'; 2 | import { ReadonlyVisitor } from '@nestjs/swagger/dist/plugin/index.js'; 3 | 4 | const generator = new PluginMetadataGenerator(); 5 | generator.generate({ 6 | visitors: [ 7 | new ReadonlyVisitor({ 8 | introspectComments: true, 9 | esmCompatible: true, 10 | pathToSource: `${import.meta.dirname}/../src`, 11 | }), 12 | ], 13 | outputDir: `${import.meta.dirname}/../src`, 14 | watch: false, 15 | tsconfigPath: 'tsconfig.json', 16 | }); 17 | -------------------------------------------------------------------------------- /src/config/envs/default.ts: -------------------------------------------------------------------------------- 1 | import { MySqlDriver } from '@mikro-orm/mysql'; 2 | import type { MikroOrmModuleOptions } from '@mikro-orm/nestjs'; 3 | 4 | export const config = { 5 | mikro: { 6 | driver: MySqlDriver, 7 | // entities: [`${import.meta.dirname}/../../entities`], 8 | // entitiesTs: [`${import.meta.dirname}/../../entities`], 9 | autoLoadEntities: true, 10 | dbName: 'test', 11 | // timezone: '+09:00', 12 | allowGlobalContext: true, 13 | } satisfies MikroOrmModuleOptions, 14 | 15 | hello: 'world', 16 | jwtSecret: process.env.JWT_SECRET, 17 | }; 18 | -------------------------------------------------------------------------------- /src/config/envs/development.ts: -------------------------------------------------------------------------------- 1 | import type { MySqlDriver } from '@mikro-orm/mysql'; 2 | import type { MikroOrmModuleOptions } from '@mikro-orm/nestjs'; 3 | 4 | export const config = { 5 | env: 'development', 6 | 7 | mikro: { 8 | debug: true, 9 | host: process.env['DB_HOST'] ?? '127.0.0.1', 10 | user: process.env['DB_USER'], 11 | password: process.env['DB_PASSWORD'], 12 | pool: { 13 | min: 0, 14 | max: 5, 15 | idleTimeoutMillis: 10000, 16 | acquireTimeoutMillis: 10000, 17 | destroyTimeoutMillis: 60000, 18 | }, 19 | } satisfies MikroOrmModuleOptions, 20 | }; 21 | -------------------------------------------------------------------------------- /typings/global.d.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../src/user/index.ts'; 2 | 3 | export declare global { 4 | namespace NodeJS { 5 | interface ProcessEnv { 6 | NODE_ENV: string; 7 | PORT: string; 8 | 9 | DATABASE_URL: string; 10 | JWT_SECRET: string; 11 | } 12 | } 13 | } 14 | 15 | declare module 'http' { 16 | interface IncomingMessage { 17 | // customProps of pino-http 18 | customProps: object; 19 | // Request.prototype of fastify 20 | originalUrl: string; 21 | } 22 | } 23 | 24 | declare module 'fastify' { 25 | interface FastifyRequest { 26 | user: User; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { HealthCheck, HealthCheckService, MikroOrmHealthIndicator, type HealthCheckResult } from '@nestjs/terminus'; 3 | 4 | /** 5 | * https://docs.nestjs.com/recipes/terminus 6 | */ 7 | @Controller() 8 | export class HealthController { 9 | constructor( 10 | private health: HealthCheckService, 11 | private db: MikroOrmHealthIndicator, 12 | ) {} 13 | 14 | @Get('health') 15 | @HealthCheck() 16 | public async check(): Promise { 17 | return this.health.check([async () => this.db.pingCheck('database')]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy } from 'passport-jwt'; 4 | 5 | import type { JwtPayload, Payload } from '../auth.interface.js'; 6 | 7 | @Injectable() 8 | export class JwtStrategy extends PassportStrategy(Strategy) { 9 | constructor() { 10 | super({ 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 12 | ignoreExpiration: false, 13 | secretOrKey: process.env.JWT_SECRET, 14 | }); 15 | } 16 | 17 | public validate(payload: JwtPayload): Payload { 18 | return { id: payload.sub, name: payload.username }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/entities/test/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { Collection, Entity, ManyToMany, type Opt, PrimaryKey, Property } from '@mikro-orm/core'; 2 | 3 | import { Memo } from './memo.entity.js'; 4 | 5 | @Entity({ tableName: 'category' }) 6 | export class Category { 7 | @PrimaryKey() 8 | id!: number; 9 | 10 | @Property({ unique: true }) 11 | name!: string; 12 | 13 | @ManyToMany(() => Memo, 'categories', { nullable: true }) 14 | memos = new Collection(this); 15 | 16 | @Property({ type: 'datetime', columnType: 'timestamp', defaultRaw: `CURRENT_TIMESTAMP` }) 17 | updatedAt!: Date & Opt; 18 | 19 | @Property({ type: 'datetime', columnType: 'timestamp', defaultRaw: `CURRENT_TIMESTAMP` }) 20 | createdAt!: Date & Opt; 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 { User } from '../../user/index.js'; 6 | import { AuthService } from '../auth.service.js'; 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 user; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common'; 2 | import type { FastifyRequest } from 'fastify'; 3 | 4 | import type { Payload } from './auth.interface.js'; 5 | import { AuthService } from './auth.service.js'; 6 | import { JwtAuthGuard, LocalAuthGuard } from './guards/index.js'; 7 | 8 | @Controller() 9 | export class AuthController { 10 | constructor(private auth: AuthService) {} 11 | 12 | @UseGuards(LocalAuthGuard) 13 | @Post('auth/login') 14 | public login(@Req() req: FastifyRequest): { access_token: string } { 15 | return this.auth.login(req.user); 16 | } 17 | 18 | @UseGuards(JwtAuthGuard) 19 | @Get('auth/check') 20 | public check(@Req() req: FastifyRequest): Payload { 21 | return req.user; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/entities/test/memo.entity.ts: -------------------------------------------------------------------------------- 1 | import { Collection, Entity, ManyToMany, type Opt, PrimaryKey, Property } from '@mikro-orm/core'; 2 | 3 | import { Category } from './category.entity.js'; 4 | 5 | @Entity({ tableName: 'memo' }) 6 | export class Memo { 7 | @PrimaryKey() 8 | id!: number; 9 | 10 | @Property() 11 | title!: string; 12 | 13 | @Property({ nullable: true }) 14 | content?: string; 15 | 16 | @ManyToMany(() => Category, 'memos', { owner: true, nullable: true }) 17 | categories = new Collection(this); 18 | 19 | @Property({ type: 'datetime', columnType: 'timestamp', defaultRaw: `CURRENT_TIMESTAMP` }) 20 | updatedAt!: Date & Opt; 21 | 22 | @Property({ type: 'datetime', columnType: 'timestamp', defaultRaw: `CURRENT_TIMESTAMP` }) 23 | createdAt!: Date & Opt; 24 | } 25 | -------------------------------------------------------------------------------- /src/config/envs/production.ts: -------------------------------------------------------------------------------- 1 | import type { MySqlDriver } from '@mikro-orm/mysql'; 2 | import type { MikroOrmModuleOptions } from '@mikro-orm/nestjs'; 3 | 4 | export const config = { 5 | env: 'production', 6 | 7 | mikro: { 8 | host: process.env['DB_HOST'] ?? '127.0.0.1', 9 | user: process.env['DB_USER'], 10 | password: process.env['DB_PASSWORD'], 11 | replicas: [ 12 | { 13 | name: 'read', 14 | host: process.env['DB_HOST'] ?? '127.0.0.1', 15 | user: process.env['DB_USER'], 16 | password: process.env['DB_PASSWORD'], 17 | }, 18 | ], 19 | pool: { 20 | min: 0, 21 | max: 30, 22 | idleTimeoutMillis: 10000, 23 | acquireTimeoutMillis: 10000, 24 | destroyTimeoutMillis: 60000, 25 | }, 26 | } satisfies MikroOrmModuleOptions, 27 | }; 28 | -------------------------------------------------------------------------------- /src/common/exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { type ArgumentsHost, Catch, HttpException, HttpStatus } from '@nestjs/common'; 2 | import { BaseExceptionFilter } from '@nestjs/core'; 3 | 4 | @Catch() 5 | export class ExceptionsFilter extends BaseExceptionFilter { 6 | public override catch(exception: unknown, host: ArgumentsHost): void { 7 | super.catch(exception, host); 8 | 9 | const status = this.getHttpStatus(exception); 10 | if (status === HttpStatus.INTERNAL_SERVER_ERROR) { 11 | // Notifications 12 | // const request = host.switchToHttp().getRequest(); 13 | // request.method, request.originalUrl... 14 | } 15 | } 16 | 17 | private getHttpStatus(exception: unknown): HttpStatus { 18 | return exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | 5 | import { AuthController } from './auth.controller.js'; 6 | import { AuthService } from './auth.service.js'; 7 | import { LocalStrategy, JwtStrategy } from './strategies/index.js'; 8 | import { UserModule } from '../user/index.js'; 9 | 10 | @Module({ 11 | imports: [ 12 | UserModule, 13 | JwtModule.registerAsync({ 14 | useFactory: (config: ConfigService) => ({ 15 | secret: config.get('jwtSecret'), 16 | signOptions: { expiresIn: '1d' }, 17 | }), 18 | inject: [ConfigService], 19 | }), 20 | ], 21 | controllers: [AuthController], 22 | providers: [AuthService, LocalStrategy, JwtStrategy], 23 | exports: [AuthService], 24 | }) 25 | export class AuthModule {} 26 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | 4 | import type { JwtPayload } from './auth.interface.js'; 5 | import { type User, UserService } from '../user/index.js'; 6 | 7 | @Injectable() 8 | export class AuthService { 9 | constructor( 10 | private jwt: JwtService, 11 | private user: UserService, 12 | ) {} 13 | 14 | public async validateUser(username: string, password: string): Promise { 15 | const user = await this.user.fetch(username); 16 | 17 | if (user?.password === password) { 18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars, sonarjs/no-unused-vars 19 | const { password: pass, ...result } = user; 20 | return result; 21 | } 22 | 23 | return null; 24 | } 25 | 26 | public login(user: User): { access_token: string } { 27 | const payload: JwtPayload = { username: user.name, sub: user.id }; 28 | 29 | return { 30 | access_token: this.jwt.sign(payload), 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app.middleware.ts: -------------------------------------------------------------------------------- 1 | import compression from '@fastify/compress'; 2 | import cookie from '@fastify/cookie'; 3 | import helmet from '@fastify/helmet'; 4 | import { Authenticator } from '@fastify/passport'; 5 | import session from '@fastify/session'; 6 | import type { INestApplication } from '@nestjs/common'; 7 | import type { NestFastifyApplication } from '@nestjs/platform-fastify'; 8 | 9 | export async function middleware(app: NestFastifyApplication): Promise { 10 | const isProduction = process.env.NODE_ENV === 'production'; 11 | 12 | await app.register(compression); 13 | await app.register(cookie); 14 | await app.register(session, { 15 | // Requires 'store' setup for production 16 | secret: 'nEsTjS-pRoJeCt-PeRfOrMaNcE-tEsTeD', 17 | rolling: true, 18 | saveUninitialized: true, 19 | cookie: { secure: isProduction }, 20 | }); 21 | 22 | const passport = new Authenticator(); 23 | await app.register(passport.initialize()); 24 | await app.register(passport.secureSession()); 25 | await app.register(helmet); 26 | 27 | return app; 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023", 4 | "outDir": "dist", 5 | "module": "nodenext", 6 | "moduleResolution": "nodenext", 7 | "incremental": true, 8 | "declaration": true, 9 | "newLine": "lf", 10 | "strict": true, 11 | "allowUnreachableCode": false, 12 | "allowUnusedLabels": false, 13 | "forceConsistentCasingInFileNames": true, 14 | "noEmitOnError": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitOverride": true, 17 | "noImplicitReturns": true, 18 | "noPropertyAccessFromIndexSignature": true, 19 | "noUncheckedSideEffectImports": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "useDefineForClassFields": false, 23 | "verbatimModuleSyntax": true, 24 | "removeComments": true, 25 | "sourceMap": true, 26 | "experimentalDecorators": true, 27 | "emitDecoratorMetadata": true, 28 | "esModuleInterop": true, 29 | "skipLibCheck": true 30 | }, 31 | "include": ["typings/global.d.ts", "src/**/*", "test/**/*", "bin/**/*"], 32 | "exclude": ["node_modules"] 33 | } 34 | -------------------------------------------------------------------------------- /src/config/configuration.ts: -------------------------------------------------------------------------------- 1 | import type { Config, Default, Objectype, Production } from './config.interface.js'; 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(`${import.meta.dirname}/envs/default.js`); 22 | const { config: environment } = <{ config: Production }>( 23 | await import(`${import.meta.dirname}/envs/${process.env.NODE_ENV || 'development'}.js`) 24 | ); 25 | 26 | // object deep merge 27 | return util.merge(config, environment); 28 | }; 29 | -------------------------------------------------------------------------------- /src/swagger.ts: -------------------------------------------------------------------------------- 1 | import { Logger as NestLogger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { FastifyAdapter } from '@nestjs/platform-fastify'; 4 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 5 | 6 | import { AppModule } from './app.module.js'; 7 | import metadata from './metadata.js'; 8 | 9 | /** 10 | * https://docs.nestjs.com/recipes/swagger 11 | */ 12 | async function bootstrap(): Promise { 13 | const app = await NestFactory.create(AppModule, new FastifyAdapter({})); 14 | 15 | const options = new DocumentBuilder() 16 | .setTitle('OpenAPI Documentation') 17 | .setDescription('The sample API description') 18 | .setVersion('1.0') 19 | .addBearerAuth() 20 | .build(); 21 | 22 | await SwaggerModule.loadPluginMetadata(metadata); 23 | const document = SwaggerModule.createDocument(app, options); 24 | SwaggerModule.setup('api', app, document); 25 | 26 | await app.listen(process.env.PORT || 8000); 27 | 28 | return app.getUrl(); 29 | } 30 | 31 | try { 32 | const url = await bootstrap(); 33 | NestLogger.log(url, 'Bootstrap'); 34 | } catch (error) { 35 | NestLogger.error(error, 'Bootstrap'); 36 | } 37 | -------------------------------------------------------------------------------- /src/config/logger.config.ts: -------------------------------------------------------------------------------- 1 | import type { RawRequestDefaultExpression, RawServerBase } from 'fastify'; 2 | import type { Params } from 'nestjs-pino'; 3 | import { randomUUID } from 'node:crypto'; 4 | import { multistream } from 'pino'; 5 | 6 | const passUrl = new Set(['/health']); 7 | 8 | export const genReqId = (req: RawRequestDefaultExpression) => req.headers['X-Request-Id'] || randomUUID(); 9 | export const loggerOptions = { 10 | pinoHttp: [ 11 | { 12 | quietReqLogger: true, 13 | ...(process.env.NODE_ENV === 'production' 14 | ? {} 15 | : { 16 | level: 'debug', 17 | transport: { 18 | target: 'pino-pretty', 19 | options: { sync: true, singleLine: true }, 20 | }, 21 | }), 22 | autoLogging: { 23 | ignore: (req) => passUrl.has(req.originalUrl), 24 | }, 25 | customProps: (req) => req.customProps, 26 | }, 27 | multistream( 28 | [ 29 | { level: 'debug', stream: process.stdout }, 30 | { level: 'error', stream: process.stderr }, 31 | { level: 'fatal', stream: process.stderr }, 32 | ], 33 | { dedupe: true }, 34 | ), 35 | ], 36 | }; 37 | -------------------------------------------------------------------------------- /bin/synchronize.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { select } from '@inquirer/prompts'; 3 | import { MikroORM } from '@mikro-orm/core'; 4 | import { config as dotfig } from 'dotenv'; 5 | 6 | import { configuration } from '../src/config/index.js'; 7 | 8 | dotfig(); 9 | if (!process.env['DB_HOST']) { 10 | throw new Error('Create a .env file'); 11 | } 12 | 13 | // https://mikro-orm.io/docs/schema-generator 14 | (async () => { 15 | const { mikro: config } = await configuration(); 16 | 17 | const dbName = await select({ 18 | message: 'Please select a database name.', 19 | choices: [ 20 | { value: 'test' }, 21 | { value: 'test2' }, 22 | // ... 23 | ], 24 | }); 25 | 26 | const orm = await MikroORM.init({ 27 | entities: [`${import.meta.dirname}/../src/entities/${dbName}`], 28 | entitiesTs: [`${import.meta.dirname}/../src/entities/${dbName}`], 29 | driver: config.driver, 30 | host: config.host, 31 | user: config.user, 32 | password: config.password, 33 | dbName, 34 | schemaGenerator: { 35 | createForeignKeyConstraints: false, 36 | }, 37 | }); 38 | const generator = orm.schema; 39 | 40 | const createDump = await generator.getCreateSchemaSQL(); 41 | console.log(createDump); 42 | 43 | // DIY 44 | await generator.createSchema(); 45 | 46 | await orm.close(true); 47 | })().catch((error: unknown) => { 48 | console.error(error); 49 | }); 50 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; 4 | import { Logger as PinoLogger, LoggerErrorInterceptor } from 'nestjs-pino'; 5 | 6 | import { middleware } from './app.middleware.js'; 7 | import { AppModule } from './app.module.js'; 8 | import { genReqId } from './config/index.js'; 9 | 10 | async function bootstrap(): Promise { 11 | const isProduction = process.env.NODE_ENV === 'production'; 12 | 13 | const app = await NestFactory.create( 14 | AppModule, 15 | new FastifyAdapter({ 16 | trustProxy: isProduction, 17 | // Fastify has pino built in, but it use nestjs-pino, so we disable the logger. 18 | logger: false, 19 | // https://github.com/iamolegga/nestjs-pino/issues/1351 20 | genReqId, 21 | }), 22 | { 23 | bufferLogs: isProduction, 24 | }, 25 | ); 26 | 27 | app.useLogger(app.get(PinoLogger)); 28 | app.useGlobalInterceptors(new LoggerErrorInterceptor()); 29 | 30 | // Fastify Middleware 31 | await middleware(app); 32 | 33 | app.enableShutdownHooks(); 34 | await app.listen(process.env.PORT || 3000); 35 | 36 | return app.getUrl(); 37 | } 38 | 39 | void (async () => { 40 | try { 41 | const url = await bootstrap(); 42 | Logger.log(url, 'Bootstrap'); 43 | } catch (error) { 44 | Logger.error(error, 'Bootstrap'); 45 | } 46 | })(); 47 | -------------------------------------------------------------------------------- /src/metadata.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default async () => { 3 | const t = { 4 | ["./entities/test/memo.entity.js"]: await import("./entities/test/memo.entity.js") 5 | }; 6 | return { "@nestjs/swagger": { "models": [[import("./sample/sample.dto.js"), { "SampleDto": { title: { required: true, type: () => String }, content: { required: false, type: () => String }, categories: { required: false, type: () => [String], minItems: 1 } } }], [import("./entities/test/memo.entity.js"), { "Memo": { id: { required: true, type: () => Number }, title: { required: true, type: () => String }, content: { required: false, type: () => String }, categories: { required: true, type: () => Object }, updatedAt: { required: true, type: () => Object }, createdAt: { required: true, type: () => Object } } }], [import("./entities/test/category.entity.js"), { "Category": { id: { required: true, type: () => Number }, name: { required: true, type: () => String }, memos: { required: true, type: () => Object }, updatedAt: { required: true, type: () => Object }, createdAt: { required: true, type: () => Object } } }]], "controllers": [[import("./auth/auth.controller.js"), { "AuthController": { "login": {}, "check": { type: Object } } }], [import("./health/health.controller.js"), { "HealthController": { "check": { type: Object } } }], [import("./sample/sample.controller.js"), { "SampleController": { "read": { type: t["./entities/test/memo.entity.js"].Memo }, "create": {}, "update": { type: t["./entities/test/memo.entity.js"].Memo }, "remove": {} } }]] } }; 7 | }; -------------------------------------------------------------------------------- /test/e2e/auth.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; 3 | import { Test } from '@nestjs/testing'; 4 | import supertest from 'supertest'; 5 | import { afterAll, beforeAll, expect, test } from 'vitest'; 6 | 7 | import { AppModule } from '../../src/app.module.js'; 8 | 9 | let app: NestFastifyApplication | undefined; 10 | let request: supertest.Agent; 11 | let token: string; 12 | 13 | beforeAll(async () => { 14 | const moduleRef = await Test.createTestingModule({ 15 | imports: [AppModule], 16 | }).compile(); 17 | 18 | app = moduleRef.createNestApplication(new FastifyAdapter()); 19 | await app.init(); 20 | await app.getHttpAdapter().getInstance().ready(); 21 | 22 | request = supertest.agent(app.getHttpServer()); 23 | }); 24 | 25 | test('POST: /auth/login', async () => { 26 | // eslint-disable-next-line sonarjs/no-hardcoded-passwords 27 | const { status, body } = await request.post('/auth/login').send({ username: 'foobar', password: 'crypto' }); 28 | 29 | expect([200, 201]).toContain(status); 30 | expect(body).toHaveProperty('access_token'); 31 | token = body.access_token; 32 | }); 33 | 34 | test('GET: /auth/check', async () => { 35 | const { body } = await request.get('/auth/check').set('Authorization', `Bearer ${token}`).expect(200); 36 | 37 | expect(body).toHaveProperty('name', 'foobar'); 38 | }); 39 | 40 | afterAll(async () => { 41 | await app?.close(); 42 | }); 43 | -------------------------------------------------------------------------------- /src/sample/sample.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Body, 4 | Get, 5 | Param, 6 | Post, 7 | Put, 8 | Delete, 9 | NotFoundException, 10 | InternalServerErrorException, 11 | ParseIntPipe, 12 | Logger, 13 | } from '@nestjs/common'; 14 | 15 | import { SampleDto } from './sample.dto.js'; 16 | import { SampleService } from './sample.service.js'; 17 | import { Memo } from '../entities/test/index.js'; 18 | 19 | @Controller('sample') 20 | export class SampleController { 21 | private readonly logger: Logger = new Logger(SampleController.name); 22 | 23 | constructor(private sample: SampleService) {} 24 | 25 | @Get('memo/:id') // http://localhost:3000/sample/memo/1 26 | public async read(@Param('id', ParseIntPipe) id: number): Promise { 27 | this.logger.log('read'); 28 | 29 | const result = await this.sample.read(id); 30 | if (!result) { 31 | throw new NotFoundException('NotFoundMemo'); 32 | } 33 | 34 | return result; 35 | } 36 | 37 | @Post('memo') 38 | public async create(@Body() body: SampleDto): Promise<{ id: number }> { 39 | this.logger.log('create'); 40 | 41 | const result = await this.sample.create(body); 42 | if (!result.id) { 43 | throw new InternalServerErrorException('NotCreatedMemo'); 44 | } 45 | 46 | return { id: result.id }; 47 | } 48 | 49 | @Put('memo/:id') 50 | public async update(@Param('id', ParseIntPipe) id: number, @Body() body: SampleDto): Promise { 51 | this.logger.log('update'); 52 | 53 | const result = await this.sample.update(id, body); 54 | 55 | return result; 56 | } 57 | 58 | @Delete('memo/:id') 59 | public async remove(@Param('id', ParseIntPipe) id: number): Promise<{ result: number }> { 60 | this.logger.log('remove'); 61 | 62 | const result = await this.sample.remove(id); 63 | 64 | return { result }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test/e2e/sample.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; 3 | import { Test } from '@nestjs/testing'; 4 | import supertest from 'supertest'; 5 | import { afterAll, beforeAll, expect, test } from 'vitest'; 6 | 7 | import { AppModule } from '../../src/app.module.js'; 8 | 9 | let app: NestFastifyApplication | undefined; 10 | let request: supertest.Agent; 11 | let idx: number; 12 | 13 | beforeAll(async () => { 14 | const moduleFixture = await Test.createTestingModule({ 15 | imports: [AppModule], 16 | }).compile(); 17 | 18 | app = moduleFixture.createNestApplication(new FastifyAdapter()); 19 | await app.init(); 20 | await app.getHttpAdapter().getInstance().ready(); 21 | 22 | request = supertest.agent(app.getHttpServer()); 23 | }); 24 | 25 | test('POST: /sample/memo', async () => { 26 | const { status, body } = await request.post('/sample/memo').send({ 27 | title: 'FooBar', 28 | content: 'Hello World', 29 | categories: ['foo', 'bar'], 30 | }); 31 | 32 | expect([200, 201]).toContain(status); 33 | expect(body).toHaveProperty('id'); 34 | 35 | idx = body.id; 36 | }); 37 | 38 | test('GET: /sample/memo/:idx', async () => { 39 | const { body } = await request.get(`/sample/memo/${idx}`).expect(200); 40 | 41 | expect(body).toHaveProperty('title', 'FooBar'); 42 | expect(Array.isArray(body.categories)).toBe(true); 43 | expect(body.categories).toHaveLength(2); 44 | }); 45 | 46 | test('PUT: /sample/memo/:idx', async () => { 47 | const { body } = await request 48 | .put(`/sample/memo/${idx}`) 49 | .send({ title: 'Blahblahblah', categories: ['blah'] }) 50 | .expect(200); 51 | 52 | expect(body).toHaveProperty('title', 'Blahblahblah'); 53 | }); 54 | 55 | test.skip('DELETE: /sample/memo/:idx', async () => { 56 | const { body } = await request.delete(`/sample/memo/${idx}`).expect(200); 57 | 58 | expect(body).toHaveProperty('result', 1); 59 | }); 60 | 61 | afterAll(async () => { 62 | await app?.close(); 63 | }); 64 | -------------------------------------------------------------------------------- /src/sample/sample.service.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager, type EntityRepository, type Loaded } from '@mikro-orm/core'; 2 | import { InjectRepository } from '@mikro-orm/nestjs'; 3 | import { Injectable } from '@nestjs/common'; 4 | 5 | import type { SampleDto } from './sample.dto.js'; 6 | import { Category, Memo } from '../entities/test/index.js'; 7 | 8 | @Injectable() 9 | export class SampleService { 10 | constructor( 11 | @InjectRepository(Memo) private memo: EntityRepository, 12 | @InjectRepository(Category) private category: EntityRepository, 13 | private em: EntityManager, 14 | ) {} 15 | 16 | public async create(data: SampleDto): Promise { 17 | const categories = data.categories ? await this.fetchCategories(data.categories) : []; 18 | const memo = this.memo.create({ ...data, categories }); 19 | await this.em.flush(); 20 | return memo; 21 | } 22 | 23 | public async read(id: number): Promise { 24 | return this.memo.findOne({ id }, { populate: ['categories'] }); 25 | } 26 | 27 | public async update(id: number, data: SampleDto): Promise { 28 | const memo = await this.memo.findOneOrFail({ id }, { populate: ['categories'] }); 29 | this.em.assign(memo, { title: data.title, content: data.content }, { ignoreUndefined: true }); 30 | 31 | if (data.categories) { 32 | const categories = await this.fetchCategories(data.categories); 33 | for (const category of categories) { 34 | if (!memo.categories.contains(category)) { 35 | memo.categories.add(category); 36 | } 37 | } 38 | } 39 | 40 | await this.em.flush(); 41 | return memo; 42 | } 43 | 44 | public async remove(id: number): Promise { 45 | return this.memo.nativeDelete({ id }); 46 | } 47 | 48 | private async fetchCategories(categories: string[]): Promise[]> { 49 | return Promise.all( 50 | categories.map(async (name) => { 51 | const category = await this.category.findOne({ name }); 52 | if (!category) { 53 | return this.category.create({ name }); 54 | } 55 | 56 | return category; 57 | }), 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | .history 4 | .DS_Store 5 | dist 6 | docs 7 | build 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | *.lcov 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (https://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules/ 50 | jspm_packages/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variables file 77 | .env 78 | .env.test 79 | 80 | # parcel-bundler cache (https://parceljs.org/) 81 | .cache 82 | 83 | # Next.js build output 84 | .next 85 | 86 | # Nuxt.js build / generate output 87 | .nuxt 88 | dist 89 | 90 | # Gatsby files 91 | .cache/ 92 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 93 | # https://nextjs.org/blog/next-9-1#public-directory-support 94 | # public 95 | 96 | # vuepress build output 97 | .vuepress/dist 98 | 99 | # Serverless directories 100 | .serverless/ 101 | 102 | # FuseBox cache 103 | .fusebox/ 104 | 105 | # DynamoDB Local files 106 | .dynamodb/ 107 | 108 | # TernJS port file 109 | .tern-port 110 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import type { MikroORMOptions } from '@mikro-orm/core'; 2 | import { MySqlDriver } from '@mikro-orm/mysql'; 3 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 4 | import { Module, ValidationPipe, type MiddlewareConsumer, type NestModule } from '@nestjs/common'; 5 | import { ConfigModule, ConfigService } from '@nestjs/config'; 6 | import { APP_FILTER, APP_PIPE } from '@nestjs/core'; 7 | import { ServeStaticModule } from '@nestjs/serve-static'; 8 | import { LoggerModule } from 'nestjs-pino'; 9 | 10 | import { AuthModule } from './auth/auth.module.js'; 11 | import { CommonModule, ExceptionsFilter, LoggerContextMiddleware } from './common/index.js'; 12 | import { configuration, loggerOptions } from './config/index.js'; 13 | import { HealthModule } from './health/health.module.js'; 14 | import { SampleModule } from './sample/sample.module.js'; 15 | 16 | @Module({ 17 | imports: [ 18 | // https://github.com/iamolegga/nestjs-pino 19 | LoggerModule.forRoot(loggerOptions), 20 | // Configuration 21 | ConfigModule.forRoot({ 22 | isGlobal: true, 23 | load: [configuration], 24 | }), 25 | // Static 26 | ServeStaticModule.forRoot({ 27 | rootPath: `${import.meta.dirname}/../public`, 28 | }), 29 | /** 30 | * https://docs.nestjs.com/recipes/mikroorm 31 | * https://mikro-orm.io/docs/usage-with-nestjs 32 | * https://mikro-orm.io 33 | */ 34 | MikroOrmModule.forRootAsync({ 35 | useFactory: (config: ConfigService) => config.getOrThrow('mikro'), 36 | inject: [ConfigService], 37 | driver: MySqlDriver, 38 | }), 39 | // Global 40 | CommonModule, 41 | // Terminus 42 | HealthModule, 43 | // Authentication 44 | AuthModule, 45 | // API Sample 46 | SampleModule, 47 | ], 48 | providers: [ 49 | { 50 | provide: APP_FILTER, 51 | useClass: ExceptionsFilter, 52 | }, 53 | { 54 | provide: APP_PIPE, 55 | useValue: new ValidationPipe({ 56 | transform: true, 57 | whitelist: true, 58 | }), 59 | }, 60 | ], 61 | }) 62 | export class AppModule implements NestModule { 63 | // Global Middleware 64 | public configure(consumer: MiddlewareConsumer): void { 65 | consumer.apply(LoggerContextMiddleware).forRoutes('*'); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /bin/entities.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { select } from '@inquirer/prompts'; 3 | import { MikroORM } from '@mikro-orm/core'; 4 | import { EntityGenerator } from '@mikro-orm/entity-generator'; 5 | import { config as dotfig } from 'dotenv'; 6 | import { readdirSync, writeFileSync } from 'node:fs'; 7 | import { join } from 'node:path'; 8 | 9 | import { configuration } from '../src/config/index.js'; 10 | 11 | dotfig(); 12 | if (!process.env['DB_HOST']) { 13 | throw new Error('Create a .env file'); 14 | } 15 | 16 | function pascalToHyphen(fileName: string): string { 17 | return fileName.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); 18 | } 19 | 20 | // https://mikro-orm.io/docs/entity-generator 21 | (async () => { 22 | const { mikro: config } = await configuration(); 23 | 24 | const dbName = await select({ 25 | message: 'Please select a database name.', 26 | choices: [ 27 | { value: 'test' }, 28 | { value: 'test2' }, 29 | // ... 30 | ], 31 | }); 32 | 33 | const orm = await MikroORM.init({ 34 | discovery: { 35 | warnWhenNoEntities: false, 36 | }, 37 | extensions: [EntityGenerator], 38 | driver: config.driver, 39 | host: config.host, 40 | user: config.user, 41 | password: config.password, 42 | dbName, 43 | }); 44 | 45 | await orm.entityGenerator.generate({ 46 | // bidirectionalRelations: true, 47 | // identifiedReferences: true, 48 | save: true, 49 | path: `${import.meta.dirname}/../src/entities/${dbName}`, 50 | fileName: (className: string) => { 51 | return `${pascalToHyphen(className)}.entity`; 52 | }, 53 | }); 54 | await orm.close(true); 55 | 56 | const entityDir = join(import.meta.dirname, '../src/entities', dbName); 57 | const files = []; 58 | 59 | for (const file of readdirSync(entityDir)) { 60 | if (file === 'index.ts') { 61 | continue; 62 | } 63 | files.push(`export * from './${file.replace('.ts', '.js')}';`); 64 | } 65 | files.push(''); 66 | // export entities db tables 67 | // AS-IS import { Tablename } from './entities/dbname/tablename'; 68 | // TO-BE import { Tablename } from './entities/dbname'; 69 | writeFileSync(join(entityDir, 'index.ts'), files.join('\n')); 70 | 71 | console.log(`> '${dbName}' database entities has been created: ${entityDir}`); 72 | })().catch((error: unknown) => { 73 | console.error(error); 74 | }); 75 | -------------------------------------------------------------------------------- /src/sample/sample.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { EntityManager, type EntityRepository } from '@mikro-orm/core'; 2 | import { getRepositoryToken } from '@mikro-orm/nestjs'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | import { afterAll, beforeAll, expect, test } from 'vitest'; 5 | import { type DeepMockProxy, mockDeep } from 'vitest-mock-extended'; 6 | 7 | import { SampleController } from './sample.controller.js'; 8 | import { SampleService } from './sample.service.js'; 9 | import { Category, Memo } from '../entities/test/index.js'; 10 | 11 | let app: TestingModule | undefined; 12 | let repository: DeepMockProxy>; 13 | let sample: SampleController; 14 | let mockValue: Memo; 15 | 16 | beforeAll(async () => { 17 | app = await Test.createTestingModule({ 18 | controllers: [SampleController], 19 | providers: [ 20 | SampleService, 21 | { 22 | provide: getRepositoryToken(Memo), 23 | useValue: mockDeep>(), 24 | }, 25 | { 26 | provide: getRepositoryToken(Category), 27 | useValue: mockDeep>(), 28 | }, 29 | { 30 | provide: EntityManager, 31 | useValue: mockDeep(), 32 | }, 33 | ], 34 | }).compile(); 35 | 36 | const em: DeepMockProxy = app.get(EntityManager); 37 | em.flush.mockResolvedValueOnce(undefined); 38 | 39 | repository = app.get(getRepositoryToken(Memo)); 40 | sample = app.get(SampleController); 41 | }); 42 | 43 | test('create memo', async () => { 44 | mockValue = { 45 | id: 1, 46 | title: 'FooBar', 47 | content: 'Hello World', 48 | updatedAt: new Date(), 49 | createdAt: new Date(), 50 | }; 51 | repository.create.mockResolvedValueOnce(mockValue); 52 | 53 | const result = await sample.create({ title: 'FooBar', content: 'Hello World' }); 54 | expect(result).toHaveProperty('id', 1); 55 | }); 56 | 57 | test('read memo', async () => { 58 | repository.findOne.mockResolvedValueOnce(mockValue); 59 | 60 | expect(await sample.read(1)).toHaveProperty('id', 1); 61 | }); 62 | 63 | test('update memo', async () => { 64 | mockValue.title = 'Blahblahblah'; 65 | repository.findOneOrFail.mockResolvedValueOnce(mockValue); 66 | 67 | const result = await sample.update(1, { title: mockValue.title }); 68 | expect(result).toHaveProperty('title', mockValue.title); 69 | }); 70 | 71 | test('delete memo', async () => { 72 | repository.nativeDelete.mockResolvedValueOnce(1); 73 | 74 | const result = await sample.remove(1); 75 | expect(result).toHaveProperty('result', 1); 76 | }); 77 | 78 | afterAll(async () => { 79 | await app?.close(); 80 | }); 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nestjs-project-performance 2 | 3 | Node.js framework NestJS project for performance 4 | 5 | If you focus on the performance or features of the module, you can consider using another module as an alternative in NestJS. \ 6 | In this example, the changed modules are as follows. 7 | 8 | - [Fastify](https://docs.nestjs.com/techniques/performance) instead of `Express` 9 | - [MikroORM](https://docs.nestjs.com/recipes/mikroorm) with [@mikro-orm/nestjs](https://mikro-orm.io/docs/usage-with-nestjs) instead of `TypeORM` 10 | - [SWC](https://docs.nestjs.com/recipes/swc#swc) instead of `TypeScript compiler` 11 | - [Vitest](https://docs.nestjs.com/recipes/swc#vitest) with [vitest-mock-extended](https://www.npmjs.com/package/vitest-mock-extended) instead of `Jest` 12 | - [ESM](https://nodejs.org/api/esm.html) instead of `CommonJS` 13 | 14 | ## Configuration 15 | 16 | 1. Create a `.env` file 17 | - Rename the [.env.sample](.env.sample) file to `.env` to fix it. 18 | 2. Edit env config 19 | - Edit the file in the [config/envs](src/config/envs) folder. 20 | - `default`, `development`, `production`, `test` 21 | 22 | ## Installation 23 | 24 | ```sh 25 | # 1. node_modules 26 | npm ci 27 | # 2. When synchronize database from existing entities 28 | npm run entity:sync 29 | # 2-1. When import entities from an existing database 30 | npm run entity:load 31 | ``` 32 | 33 | ## Development 34 | 35 | ```sh 36 | npm run start:dev 37 | ``` 38 | 39 | Run [http://localhost:3000](http://localhost:3000) 40 | 41 | ## Test 42 | 43 | ```sh 44 | npm test # exclude e2e 45 | npm run test:e2e # only e2e 46 | ``` 47 | 48 | ## Production 49 | 50 | ```sh 51 | # define NODE_ENV and PORT 52 | npm run build 53 | # NODE_ENV=production PORT=8000 node dist/app.js 54 | node dist/app.js 55 | ``` 56 | 57 | ## Documentation 58 | 59 | ```sh 60 | # https://docs.nestjs.com/openapi/cli-plugin#swc-builder 61 | # Update the metadata before running the swagger server. 62 | npm run esm bin/generate-metadata.ts 63 | # API, Swagger - src/swagger.ts 64 | npm run doc:api #> http://localhost:8000/api 65 | ``` 66 | 67 | ## Implements 68 | 69 | - See [app](src/app.ts), [app.module](src/app.module.ts) 70 | - [Exceptions Filter](src/common/exceptions.filter.ts) 71 | - [Logging Context Middleware](src/common/logger-context.middleware.ts) 72 | - [Custom Logger](src/config/logger.config.ts) with nestjs-pino 73 | - [Configuration by Environment](src/config/envs) 74 | - [JWT Authentication](src/auth) 75 | - [CRUD API Sample](src/sample) 76 | - [Unit Test](src/sample/sample.controller.spec.ts) 77 | - [E2E Test](test/e2e) 78 | 79 | ### Links 80 | 81 | - [Nest Project Structure](https://github.com/CatsMiaow/node-nestjs-structure) 82 | - [NestJS](https://docs.nestjs.com) 83 | - [Fastify](https://fastify.dev) 84 | - [MikroORM](https://mikro-orm.io) 85 | - [Vitest](https://vitest.dev) 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-project-performance", 3 | "version": "0.0.0", 4 | "description": "Node.js framework NestJS project for performance", 5 | "license": "MIT", 6 | "private": true, 7 | "type": "module", 8 | "engines": { 9 | "node": ">=20.19.0 || >=22.12.0" 10 | }, 11 | "scripts": { 12 | "lint": "eslint", 13 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 14 | "build": "nest build", 15 | "start": "node dist/app", 16 | "start:dev": "nest start --watch", 17 | "start:debug": "nest start --debug --watch", 18 | "test": "vitest run --exclude **/e2e/**/*.ts", 19 | "test:e2e": "vitest run --config ./test/vitest.e2e.ts", 20 | "esm": "node --import @swc-node/register/esm-register", 21 | "entity:load": "npm run esm ./bin/entities.ts", 22 | "entity:sync": "npm run esm ./bin/synchronize.ts", 23 | "doc:api": "nest build && node dist/swagger" 24 | }, 25 | "dependencies": { 26 | "@fastify/compress": "^8.1.0", 27 | "@fastify/cookie": "^11.0.2", 28 | "@fastify/helmet": "^13.0.2", 29 | "@fastify/passport": "^3.0.2", 30 | "@fastify/session": "^11.1.0", 31 | "@fastify/static": "^8.3.0", 32 | "@mikro-orm/core": "^6.5.9", 33 | "@mikro-orm/mysql": "^6.5.9", 34 | "@mikro-orm/nestjs": "^6.1.1", 35 | "@nestjs/common": "^11.1.8", 36 | "@nestjs/config": "4.0.2", 37 | "@nestjs/core": "^11.1.8", 38 | "@nestjs/jwt": "^11.0.1", 39 | "@nestjs/passport": "^11.0.5", 40 | "@nestjs/platform-fastify": "^11.1.8", 41 | "@nestjs/serve-static": "^5.0.4", 42 | "@nestjs/swagger": "^11.2.1", 43 | "@nestjs/terminus": "^11.0.0", 44 | "class-transformer": "^0.5.1", 45 | "class-validator": "^0.14.2", 46 | "nestjs-pino": "^4.4.1", 47 | "passport": "^0.7.0", 48 | "passport-jwt": "^4.0.1", 49 | "passport-local": "^1.0.0", 50 | "pino": "^9.14.0", 51 | "pino-http": "^10.5.0", 52 | "reflect-metadata": "^0.2.2", 53 | "rxjs": "^7.8.2" 54 | }, 55 | "devDependencies": { 56 | "@eslint/js": "^9.38.0", 57 | "@inquirer/prompts": "^7.9.0", 58 | "@mikro-orm/cli": "^6.5.9", 59 | "@mikro-orm/entity-generator": "^6.5.9", 60 | "@nestjs/cli": "^11.0.10", 61 | "@nestjs/testing": "^11.1.8", 62 | "@swc-node/register": "^1.11.1", 63 | "@swc/cli": "^0.7.8", 64 | "@swc/core": "^1.13.5", 65 | "@types/node": "^22.18.12", 66 | "@types/passport-jwt": "^4.0.1", 67 | "@types/passport-local": "^1.0.38", 68 | "@types/supertest": "^6.0.3", 69 | "@vitest/eslint-plugin": "^1.3.25", 70 | "eslint": "^9.38.0", 71 | "eslint-config-prettier": "^10.1.8", 72 | "eslint-import-resolver-typescript": "^4.4.4", 73 | "eslint-plugin-import": "^2.32.0", 74 | "eslint-plugin-prettier": "^5.5.4", 75 | "eslint-plugin-sonarjs": "^3.0.5", 76 | "pino-pretty": "^13.1.2", 77 | "prettier": "^3.6.2", 78 | "supertest": "^7.1.4", 79 | "typescript": "~5.9.3", 80 | "typescript-eslint": "^8.46.2", 81 | "unplugin-swc": "^1.5.8", 82 | "vitest": "^4.0.3", 83 | "vitest-mock-extended": "^3.1.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-default-export */ 2 | import eslint from '@eslint/js'; 3 | import vitest from '@vitest/eslint-plugin'; 4 | import { defineConfig } from 'eslint/config'; 5 | import importPlugin from 'eslint-plugin-import'; 6 | import prettierRecommended from 'eslint-plugin-prettier/recommended'; 7 | import sonarjs from 'eslint-plugin-sonarjs'; 8 | import { configs, plugin } from 'typescript-eslint'; 9 | 10 | // https://eslint.org/docs/latest/use/configure/configuration-files#typescript-configuration-files 11 | export default defineConfig( 12 | eslint.configs.recommended, 13 | configs.recommendedTypeChecked, 14 | configs.strictTypeChecked, 15 | configs.stylisticTypeChecked, 16 | prettierRecommended, 17 | sonarjs.configs.recommended, 18 | vitest.configs.recommended, 19 | { 20 | ignores: ['**/node_modules/**', 'dist/**'], 21 | }, 22 | { 23 | languageOptions: { 24 | parserOptions: { 25 | // projectService: true, 26 | projectService: { 27 | allowDefaultProject: ['*.cjs', '*.mjs'], 28 | }, 29 | tsconfigRootDir: import.meta.dirname, 30 | }, 31 | }, 32 | plugins: { 33 | '@typescript-eslint': plugin, 34 | vitest, 35 | }, 36 | // https://github.com/import-js/eslint-plugin-import?tab=readme-ov-file#config---flat-with-config-in-typescript-eslint 37 | extends: [importPlugin.flatConfigs.recommended, importPlugin.flatConfigs.typescript], 38 | settings: { 39 | 'import/resolver': { 40 | typescript: true, 41 | node: true, 42 | }, 43 | }, 44 | // These rules are for reference only. 45 | rules: { 46 | //#region eslint 47 | 'class-methods-use-this': 'off', 48 | 'no-console': 'error', 49 | 'no-continue': 'off', 50 | 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], 51 | 'no-void': ['error', { allowAsStatement: true }], 52 | 'spaced-comment': ['error', 'always', { line: { markers: ['/', '#region', '#endregion'] } }], 53 | //#endregion 54 | 55 | //#region import 56 | 'import/no-default-export': 'error', 57 | 'import/order': [ 58 | 'error', 59 | { 60 | groups: [['builtin', 'external', 'internal']], 61 | 'newlines-between': 'always', 62 | alphabetize: { order: 'asc', caseInsensitive: true }, 63 | }, 64 | ], 65 | 'import/prefer-default-export': 'off', 66 | //#endregion 67 | 68 | //#region @typescript-eslint 69 | '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'angle-bracket' }], 70 | '@typescript-eslint/no-extraneous-class': 'off', 71 | '@typescript-eslint/no-misused-spread': 'off', 72 | '@typescript-eslint/no-unsafe-member-access': 'off', 73 | '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], 74 | //#endregion 75 | 76 | //#region sonarjs 77 | // https://community.sonarsource.com/t/eslint-plugin-sonarjs-performance-issues-on-large-codebase/138392 78 | 'sonarjs/no-commented-code': 'off', 79 | //#endregion 80 | }, 81 | }, 82 | ); 83 | --------------------------------------------------------------------------------