Initial commit

This commit is contained in:
david.hon
2023-04-17 10:20:39 +08:00
parent 164373aa05
commit 8c78c2ad98
54 changed files with 23165 additions and 2 deletions

View File

@@ -0,0 +1,28 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('healthcheck', () => {
it('should return "OK"', () => {
expect(appController.healthcheck()).toBe('OK');
});
});
describe('version', () => {
it('should return "OK"', () => {
expect(appController.healthcheck()).toBe(process.env.APP_VERSION);
});
});
});

17
src/app.controller.ts Normal file
View File

@@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('/healthcheck')
healthcheck(): any {
return this.appService.healthcheck();
}
@Get('/version')
version(): any {
return this.appService.version();
}
}

57
src/app.module.ts Normal file
View File

@@ -0,0 +1,57 @@
import {
Logger,
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { AllExceptionsFilter } from './logger/any-exception.filter';
import loggerMiddleware from './logger/logger.middleware';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StaffsModule } from './staffs/staffs.module';
@Module({
imports: [
// For Configuration
ConfigModule.forRoot(),
// For ORM-MySQL
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: 'example',
database: 'example_nodejs_nest_crud',
// entities: ['dist/modules/**/*.mysql.entity{.ts,.js}'],
autoLoadEntities: true,
// IMPORTANT: disable sync
// synchronize: false,
synchronize: true,
}),
UsersModule,
StaffsModule,
],
controllers: [AppController],
providers: [
Logger,
AppService,
{
provide: APP_FILTER,
// useClass: HttpExceptionFilter,
useClass: AllExceptionsFilter,
},
],
})
export class AppModule implements NestModule {
// Apply logger middleware for all routes
configure(consumer: MiddlewareConsumer) {
consumer
.apply(loggerMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

19
src/app.service.ts Normal file
View File

@@ -0,0 +1,19 @@
import { Logger, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AppService {
constructor(
private readonly logger: Logger,
private configService: ConfigService,
) {}
healthcheck() {
return 'OK';
}
version() {
this.logger.debug(
this.configService.get<string>('APP_VERSION') || 'Not APP_VERSION',
);
return process.env.APP_VERSION || '0.0.0';
}
}

View File

@@ -0,0 +1,77 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { error } from 'console';
// Catch any exception
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const { body, url, method, originalUrl, rawHeaders } = request;
let message = exception as string;
let stack = undefined;
if (exception instanceof Error) {
message = exception.message;
// trim the stack lines
if (exception.stack) {
stack = exception.stack.split('\n').slice(1, 3).join('\n');
}
}
// For HTTP Exception, don't log the stack
// if (exception instanceof HttpException) {
// stack = null;
// }
let logMsg: any;
if (process.env.NODE_ENV === 'production') {
logMsg = {
message,
request: {
method,
url,
body,
// originalUrl,
rawHeaders,
},
};
this.logger.error(logMsg, stack, AllExceptionsFilter.name);
} else {
const requstBody =
Object.keys(body).length > 0 ? ` Body=${JSON.stringify(body)}` : '';
const rawHeadersStr =
Object.keys(rawHeaders).length > 0
? ` RawHeaders=${JSON.stringify(rawHeaders)}`
: '';
logMsg = `${message}\n${method.toLocaleUpperCase()} ${url}${requstBody}${rawHeadersStr}`;
this.logger.error(logMsg, stack, AllExceptionsFilter.name);
}
response.header('Content-Type', 'application/json; charset=utf-8');
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
error: message,
});
}
}

88
src/logger/index.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* index.ts: logging utilities
*
* See:
* - https://github.com/gremo/nest-winston
* - https://github.com/winstonjs/winston
*
* (C) 2023
* Price.com.hk
*/
import * as winston from 'winston';
import { utilities as nestWinstonModuleUtilities } from 'nest-winston';
const { combine, timestamp, printf, colorize } = winston.format;
// Wrapper of createLogger, with the common options used for Price's usvc
export function createLogger(options?: winston.LoggerOptions): winston.Logger {
const logLevel = process.env.LOG_LEVEL
? process.env.LOG_LEVEL
: process.env.NODE_ENV === 'production'
? 'info'
: 'debug';
if (process.env.NODE_ENV === 'production') {
return winston.createLogger({
...options,
transports: [new winston.transports.Console()],
format: combine(
nestWinstonModuleUtilities.format.nestLike(),
winston.format.json(),
winston.format((info) => {
// Remove reduntant 'value' while object is logged instead of msg string
if (info.value) {
delete info.value;
}
// Remove timestamp (for changing timestamp to 'time')
if (info.timestamp) {
delete info.timestamp;
return info;
}
return info;
})(),
timestamp({
// format: 'YYYY-MM-DDTHH:mm:ss.SSSZZ',
format: 'YYYY-MM-DDTHH:mm:ssZZ',
alias: 'time',
}),
),
level: logLevel,
});
} else {
return winston.createLogger({
...options,
transports: [new winston.transports.Console()],
format: combine(
nestWinstonModuleUtilities.format.nestLike(),
// timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
timestamp({ format: 'HH:mm:ss' }),
colorize(),
// Format for debug-console, not in JSON meta param is ensured by splat()
printf(
({
context,
namespace,
module,
timestamp,
level,
message,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
value, // No print
...meta
}) => {
const infoContext = context ? `[${context}] ` : '';
const infoString =
module || namespace ? `[${module || namespace}] ` : '';
return `${timestamp} ${level.padEnd(
15,
)} ${infoString}${infoContext}${message}${
Object.keys(meta).length > 0 ? ' - ' + JSON.stringify(meta) : ''
}`;
},
),
),
level: logLevel,
});
}
}

View File

@@ -0,0 +1,71 @@
import {
Inject,
Injectable,
NestMiddleware,
Logger,
LoggerService,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export default class LoggerMiddleware implements NestMiddleware {
// TODO: Access log if env not production
constructor(@Inject(Logger) private readonly logger: LoggerService) {}
use(req: Request, res: Response, next: NextFunction) {
if (process.env.NODE_ENV === 'production') {
const logObj = {
message: 'web-request',
request: this.requestInJson(req),
};
if (process.env.LOG_LEVEL_INFO_FOR_WEB_REQUEST === 'true') {
this.logger.log(logObj, LoggerMiddleware.name);
} else {
this.logger.debug(logObj, LoggerMiddleware.name);
}
} else {
const logMsg = this.requestInLine(req);
if (process.env.LOG_LEVEL_INFO_FOR_WEB_REQUEST === 'true') {
this.logger.log(logMsg, LoggerMiddleware.name);
} else {
this.logger.debug(logMsg, LoggerMiddleware.name);
}
}
next();
}
requestInLine(req: Request): string {
const { headers, url, method, body, socket } = req;
const xRealIp = headers['X-Real-IP'];
const xForwardedFor = headers['X-Forwarded-For'];
const { ip: cIp } = req;
const { remoteAddress } = socket || {};
const ip = xRealIp || xForwardedFor || cIp || remoteAddress;
const requstBody =
Object.keys(body).length > 0 ? ` Body=${JSON.stringify(body)}` : '';
const requstIp = ip ? ` SrcIP=${ip}` : '';
return `${method.toLocaleUpperCase()} ${url}${requstBody}${requstIp}`;
}
requestInJson: (req: Request) => {
[prop: string]: any;
} = (req) => {
const { headers, url, method, body, socket } = req;
const xRealIp = headers['X-Real-IP'];
const xForwardedFor = headers['X-Forwarded-For'];
const { ip: cIp } = req;
const { remoteAddress } = socket || {};
const ip = xRealIp || xForwardedFor || cIp || remoteAddress;
return {
url,
host: headers.host,
ip,
method,
body,
};
};
}

44
src/main.ts Normal file
View File

@@ -0,0 +1,44 @@
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { WinstonModule } from 'nest-winston';
import { createLogger } from './logger';
import { ValidationPipe } from '@nestjs/common';
const DEFAULT_APP_PORT = 3000;
async function bootstrap() {
const instance = createLogger({});
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger({ instance }),
});
// Use the shutdown hooks
// app.enableShutdownHooks();
// Use Validation Pipe
app.useGlobalPipes(new ValidationPipe());
// Setup the Swagger if ENABLE_SWAGGER is True OR NODE_ENV is NOT 'production'
if (
process.env.NODE_ENV !== 'production' ||
process.env.ENABLE_SWAGGER === 'true'
) {
const swaggerDoc = SwaggerModule.createDocument(
app,
new DocumentBuilder()
.setTitle('Basic Nestjs API Service')
.setDescription('Boilerplate project of the Basic Nestjs API Service')
.setVersion('1.0')
.addServer(process.env.SWAGGER_SERVER_PATH)
.build(),
);
SwaggerModule.setup('api', app, swaggerDoc);
}
// Start the web app
await app.listen(process.env.APP_PORT || DEFAULT_APP_PORT);
}
bootstrap();

10
src/staffs/README.md Normal file
View File

@@ -0,0 +1,10 @@
# Staffs Module
- This module demonstrates using:
- CRUD
- TypeORM: MySQL
- TypeORM Repository
- No QueryBuilder
- TypeOrmModule.forFeature & TypeOrmModule.forRoot{ autoLoadEntities: true }
- The Entities
- DB and Web-API entities are shared

View File

@@ -0,0 +1,36 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsNumber, IsOptional } from 'class-validator';
export class StaffCreateDto {
// NOTE: Since the id is auto-inc, so no id for the creation
// id: number;
@ApiProperty({
type: String,
name: 'name',
description: 'Staff name',
required: true,
})
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
type: Number,
name: 'staffCode',
description: 'Staff code/ID',
required: false,
})
@IsNumber()
@IsOptional()
staffCode: number;
@ApiProperty({
type: Number,
name: 'departmentId',
description: 'Department ID',
required: false,
})
@IsNumber()
@IsOptional()
departmentId: number;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { StaffCreateDto } from './staff-create.dto';
export class StaffUpdateDto extends PartialType(StaffCreateDto) {}

View File

@@ -0,0 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
// @Entity('staffs')
@Entity()
export class Staff {
@ApiProperty({
type: 'number',
name: 'id',
description: 'id of staff, auto-incremented',
required: true,
})
@PrimaryGeneratedColumn('increment')
id: number;
@ApiProperty({
type: String,
name: 'name',
description: 'Staff name',
required: true,
})
@Column({
type: 'varchar',
})
name: string;
@ApiProperty({
type: Number,
name: 'staffCode',
description: 'Staff code/ID',
required: false,
})
@Column({
name: 'staff_code',
type: 'varchar',
length: 10,
nullable: true,
})
staffCode: number;
@ApiProperty({
type: Number,
name: 'departmentId',
description: 'Department ID',
required: false,
})
@Column({
// Match existing column naming convention
name: 'department_id',
type: 'tinyint',
unsigned: true,
nullable: true,
})
departmentId: number;
// TODO: JOIN TABLE CASE
}

View File

@@ -0,0 +1,76 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
NotFoundException,
HttpCode,
BadRequestException,
} from '@nestjs/common';
import { ApiResponse } from '@nestjs/swagger';
import { StaffsService } from './staffs.service';
import { Staff } from './entities/staff.entity';
import { StaffCreateDto } from './dto/staff-create.dto';
import { StaffUpdateDto } from './dto/staff-update.dto';
import { EntityNotFoundError } from 'typeorm';
@Controller('staffs')
export class StaffsController {
constructor(private readonly staffsService: StaffsService) {}
private handleEntityNotFoundError(id: number, e: Error) {
if (e instanceof EntityNotFoundError) {
throw new NotFoundException(`Not found: id=${id}`);
} else {
throw e;
}
}
@Get()
@ApiResponse({ type: [Staff] })
findAll(): Promise<Staff[]> {
return this.staffsService.findAll();
}
@Get(':id')
@ApiResponse({ type: Staff })
async findOne(@Param('id') id: number): Promise<Staff | null> {
// NOTE: the + operator returns the numeric representation of the object.
return this.staffsService.findOne(+id).catch((err) => {
this.handleEntityNotFoundError(id, err);
return null;
});
}
@Post()
@ApiResponse({ type: [Staff] })
create(@Body() staffCreateDto: StaffCreateDto) {
return this.staffsService.create(staffCreateDto);
}
@Patch(':id')
// Status 204 because no content will be returned
@HttpCode(204)
async update(
@Param('id') id: number,
@Body() staffUpdateDto: StaffUpdateDto,
): Promise<void> {
if (Object.keys(staffUpdateDto).length === 0) {
throw new BadRequestException('Request body is empty');
}
await this.staffsService.update(+id, staffUpdateDto).catch((err) => {
this.handleEntityNotFoundError(id, err);
});
}
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: number): Promise<void> {
await this.staffsService.remove(+id).catch((err) => {
this.handleEntityNotFoundError(id, err);
});
}
}

View File

@@ -0,0 +1,15 @@
import { Logger, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StaffsService } from './staffs.service';
import { StaffsController } from './staffs.controller';
import { Staff } from './entities/staff.entity';
@Module({
imports: [TypeOrmModule.forFeature([Staff])],
controllers: [StaffsController],
providers: [Logger, StaffsService],
// If you want to use the repository outside of the module
// which imports TypeOrmModule.forFeature, you'll need to re-export the providers generated by it.
// exports: [TypeOrmModule],
})
export class StaffsModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityNotFoundError, Repository } from 'typeorm';
import { Staff } from './entities/staff.entity';
import { StaffCreateDto } from './dto/staff-create.dto';
import { StaffUpdateDto } from './dto/staff-update.dto';
@Injectable()
export class StaffsService {
constructor(
private readonly logger: Logger,
@InjectRepository(Staff)
private staffsRepository: Repository<Staff>,
) {}
create(staffCreateDto: StaffCreateDto): Promise<Staff> {
// Use Repository.create() will copy the values to destination object.
const staff = this.staffsRepository.create(staffCreateDto);
return this.staffsRepository.save(staff);
}
findAll(): Promise<Staff[]> {
return this.staffsRepository.find();
}
findOne(id: number): Promise<Staff> {
return this.staffsRepository.findOneOrFail({ where: { id } });
}
async update(id: number, staffUpdateDto: StaffUpdateDto): Promise<void> {
await this.isExist(id);
await this.staffsRepository.update({ id }, staffUpdateDto);
}
async remove(id: number): Promise<void> {
await this.isExist(id);
await this.staffsRepository.delete(id);
}
// Helper function: Check if the entity exist.
// If entity does not exsit, return the Promise.reject()
private async isExist(id: number): Promise<void> {
const cnt = await this.staffsRepository.countBy({ id });
if (cnt > 0) {
return;
} else {
return Promise.reject(new EntityNotFoundError(Staff, { where: { id } }));
}
}
}

View File

@@ -0,0 +1,39 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsNotEmpty,
IsDefined,
IsString,
IsNumber,
IsOptional,
} from 'class-validator';
export class CreateUserDto {
// NOTE: Since the id is autoinc, so no id for user creation
// @ApiProperty({
// type: 'number',
// name: 'id',
// description: 'id of user',
// required: false,
// })
// id: number;
@ApiProperty({
type: String,
name: 'name',
description: 'name of user',
required: true,
})
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
type: Number,
name: 'age',
description: 'age of user',
required: false,
})
@IsNumber()
@IsOptional()
age: number;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View File

@@ -0,0 +1,54 @@
import { ApiProperty } from '@nestjs/swagger';
import { CreateUserDto } from '../dto/create-user.dto';
import { UpdateUserDto } from '../dto/update-user.dto';
// See: https://cimpleo.com/blog/nestjs-modules-and-swagger-practices/
// export interface IUser {
// id: number;
// name: string;
// }
// export class User implements IUser {
export class User {
@ApiProperty({
type: 'number',
name: 'id',
description: 'id of user',
required: false,
})
id: number;
@ApiProperty({
type: 'string',
name: 'name',
description: 'name of user',
required: false,
})
name: string;
protected constructor() {
this.id = -1;
this.name = '';
}
// Factory methods
public static create(id = 0, name = ''): User {
const user = new User();
user.id = id;
user.name = name;
return user;
}
public static createFromCreateUserDto(
userDto: CreateUserDto | UpdateUserDto,
id?: number,
): User {
const user = new User();
if (id) {
user.id = id;
}
user.name = userDto.name;
return user;
}
}

View File

@@ -0,0 +1,49 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
} from '@nestjs/common';
import { ApiResponse } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Get()
@ApiResponse({ type: [User], status: 200 })
async findAll(): Promise<User[]> {
return this.usersService.findAll();
}
@Get(':id')
@ApiResponse({ type: User, status: 200 })
async findOne(@Param('id') id: number): Promise<User> {
return this.usersService.findOne(+id);
}
@Patch(':id')
update(
@Param('id') id: number,
@Body() updateUserDto: UpdateUserDto,
): Promise<User> {
return this.usersService.update(+id, updateUserDto);
}
@Delete(':id')
remove(@Param('id') id: number): Promise<User> {
return this.usersService.remove(+id);
}
}

10
src/users/users.module.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Logger, Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { UserRepository } from './users.repository';
@Module({
controllers: [UsersController],
providers: [Logger, UsersService, UserRepository],
})
export class UsersModule {}

View File

@@ -0,0 +1,72 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { User } from './entities/user.entity';
@Injectable()
export class UserRepository {
constructor(private readonly logger: Logger) {}
private readonly users = new Map<number, User>();
public async findAll(): Promise<User[]> {
return [...this.users.values()];
}
public async get(id: number): Promise<User> {
// const user = this.users.find((user) => user.id === id);
if (!this.users.has(id)) {
throw new NotFoundException('User not found');
}
return this.users.get(id);
}
public async add(user: User): Promise<void> {
const largestId = Math.max(...Array.from(this.users.keys()));
// Can get size from map directly but we simulate reading from external service
// So calling the method instead
// const cnt = await this.count();
// await this.get(cnt - 1)
// .then((lastuser) => {
// id = lastuser.id + 1;
// })
// .catch(() => {
// id = 0;
// });
user.id = largestId < 0 ? 0 : largestId + 1;
this.users.set(user.id, user);
this.logger.log({ message: 'User added', user });
}
public async update(id: number, user: User): Promise<User> {
// Can get size from map directly but we simulate reading from external service
// So calling the method instead
// It will throw exception if not found.
const oldUser = await this.get(id);
this.users.set(id, user);
this.logger.log({ message: 'User updated', from: oldUser, to: user });
return oldUser;
}
public async delete(id: number): Promise<User> {
let deletedUser: User;
await this.get(id)
.then((user) => {
this.users.delete(id);
this.logger.log({ message: 'User deleted', user });
deletedUser = user;
})
.catch(() => {
throw new NotFoundException(
`User not found, No user deleted, id=${id}`,
);
});
return deletedUser;
}
public async count(): Promise<number> {
return this.users.size;
}
}

View File

@@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
import { UserRepository } from './users.repository';
@Injectable()
export class UsersService {
constructor(
private readonly logger: Logger,
private readonly userRepository: UserRepository,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const user = User.createFromCreateUserDto(createUserDto, -1);
await this.userRepository.add(user);
return user;
}
async findAll(): Promise<User[]> {
return this.userRepository.findAll();
}
async findOne(id: number): Promise<User> {
return this.userRepository.get(id);
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = User.createFromCreateUserDto(updateUserDto, id);
return this.userRepository.update(id, user);
}
async remove(id: number): Promise<User> {
return this.userRepository.delete(id);
}
}