feat:改進webpack打包機制、logger可讀性、欄位驗證回傳訊息可讀性
This commit is contained in:
@ -2,7 +2,10 @@
|
|||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
|
"entryFile": "main.cjs",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"webpack": true,
|
||||||
|
"webpackConfigPath": "webpack.config.cjs",
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { join } from 'path'
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
pinoHttp: {
|
pinoHttp: {
|
||||||
level: isProd ? 'info' : 'debug',
|
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||||
serializers: {
|
serializers: {
|
||||||
req: (req: {
|
req: (req: {
|
||||||
id: string
|
id: string
|
||||||
@ -31,48 +31,59 @@ import { join } from 'path'
|
|||||||
ip: req.headers['x-forwarded-for'] ?? req.socket?.remoteAddress,
|
ip: req.headers['x-forwarded-for'] ?? req.socket?.remoteAddress,
|
||||||
userAgent: req.headers['user-agent'],
|
userAgent: req.headers['user-agent'],
|
||||||
}),
|
}),
|
||||||
|
res: (res: { statusCode: number }) => ({
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
transport: isProd
|
transport: isProd
|
||||||
? {
|
? {
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
target: 'pino-roll',
|
target: 'pino-roll',
|
||||||
level: 'info',
|
level: 'info',
|
||||||
options: {
|
options: {
|
||||||
file: join('logs', 'app'),
|
file: join('logs', 'app'),
|
||||||
frequency: 'daily',
|
frequency: 'daily',
|
||||||
dateFormat: 'yyyy-MM-dd',
|
dateFormat: 'yyyy-MM-dd',
|
||||||
extension: '.log',
|
extension: '.json',
|
||||||
limit: { count: 14 },
|
limit: { count: 14 },
|
||||||
mkdir: true,
|
mkdir: true,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
|
||||||
target: 'pino-roll',
|
|
||||||
level: 'error',
|
|
||||||
options: {
|
|
||||||
file: join('logs', 'error'),
|
|
||||||
frequency: 'daily',
|
|
||||||
dateFormat: 'yyyy-MM-dd',
|
|
||||||
extension: '.log',
|
|
||||||
limit: { count: 30 },
|
|
||||||
mkdir: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
target: 'pino-pretty',
|
|
||||||
options: {
|
|
||||||
colorize: true,
|
|
||||||
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
|
|
||||||
ignore: 'pid,hostname',
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
target: 'pino-roll',
|
||||||
|
level: 'error',
|
||||||
|
options: {
|
||||||
|
file: join('logs', 'error'),
|
||||||
|
frequency: 'daily',
|
||||||
|
dateFormat: 'yyyy-MM-dd',
|
||||||
|
extension: '.json',
|
||||||
|
limit: { count: 30 },
|
||||||
|
mkdir: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class LoggerModule {}
|
export class LoggerModule { }
|
||||||
|
|||||||
9
src/core/pipe/uuid-validation.pipe.ts
Normal file
9
src/core/pipe/uuid-validation.pipe.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { ParseUUIDPipe, BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export class UUIDValidationPipe extends ParseUUIDPipe {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
exceptionFactory: () => new BadRequestException('傳入參數非 UUID'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +1,104 @@
|
|||||||
import { BadRequestException } from '@nestjs/common'
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import type { ValidationError } from 'class-validator'
|
import { type ValidationError } from 'class-validator';
|
||||||
|
|
||||||
export function validationExceptionFactory(errors: ValidationError[]): BadRequestException {
|
const ErrorMessageMap: Record<
|
||||||
const forbidden = errors.filter((e) => e.constraints?.whitelistValidation)
|
string,
|
||||||
|
(property: string, rawMessage: string) => string
|
||||||
|
> = {
|
||||||
|
isNotEmpty: (prop) => `欄位 [${prop}] 不能為空`,
|
||||||
|
isInt: (prop) => `欄位 [${prop}] 必須是整數`,
|
||||||
|
isUuid: (prop) => `欄位 [${prop}] 必須是有效的UUID`,
|
||||||
|
isObject: (prop) => `欄位 [${prop}] 必須是物件`,
|
||||||
|
isNumber: (prop) => `欄位 [${prop}] 必須是數字`,
|
||||||
|
isString: (prop) => `欄位 [${prop}] 必須是字串`,
|
||||||
|
isEmail: (prop) => `欄位 [${prop}] 的格式不正確`,
|
||||||
|
isEnum: (prop) => `欄位 [${prop}] 的值不在允許範圍內`,
|
||||||
|
isBoolean: (prop) => `欄位 [${prop}] 必須是布林值`,
|
||||||
|
isDate: (prop) => `欄位 [${prop}] 必須是有效的日期`,
|
||||||
|
isArray: (prop) => `欄位 [${prop}] 必須是有陣列`,
|
||||||
|
isUrl: (prop) => `欄位 [${prop}] 必須是有效的網址`,
|
||||||
|
arrayNotEmpty: (prop) => `欄位 [${prop}] 陣列不能為空`,
|
||||||
|
whitelistValidation: (prop) => `欄位 [${prop}] 不在允許的名單中 (多餘欄位)`,
|
||||||
|
|
||||||
if (forbidden.length > 0) {
|
min: (prop, raw) => {
|
||||||
const fields = forbidden.map((e) => e.property).join(', ')
|
const num = raw.match(/-?\d+/)?.[0] ?? '指定';
|
||||||
return new BadRequestException(`不允許的欄位:${fields}`)
|
return `欄位 [${prop}] 最小不能低於 ${num}`;
|
||||||
}
|
},
|
||||||
|
max: (prop, raw) => {
|
||||||
|
const num = raw.match(/-?\d+/)?.[0] ?? '指定';
|
||||||
|
return `欄位 [${prop}] 最大不能超過 ${num}`;
|
||||||
|
},
|
||||||
|
minLength: (prop, raw) => {
|
||||||
|
const num = raw.match(/\d+/)?.[0] ?? '指定';
|
||||||
|
return `欄位 [${prop}] 長度至少需 ${num} 個字`;
|
||||||
|
},
|
||||||
|
maxLength: (prop, raw) => {
|
||||||
|
const num = raw.match(/\d+/)?.[0] ?? '指定';
|
||||||
|
return `欄位 [${prop}] 長度不能超過 ${num} 個字`;
|
||||||
|
},
|
||||||
|
isLength: (prop, raw) => {
|
||||||
|
const nums = raw.match(/\d+/g);
|
||||||
|
return nums && nums.length >= 2
|
||||||
|
? `欄位 [${prop}] 長度需介於 ${nums[0]} 到 ${nums[1]} 個字之間`
|
||||||
|
: `欄位 [${prop}] 長度不符規範`;
|
||||||
|
},
|
||||||
|
arrayMinSize: (prop, raw) => {
|
||||||
|
const num = raw.match(/\d+/)?.[0] ?? '指定';
|
||||||
|
return `欄位 [${prop}] 至少需包含 ${num} 個項目`;
|
||||||
|
},
|
||||||
|
arrayMaxSize: (prop, raw) => {
|
||||||
|
const num = raw.match(/\d+/)?.[0] ?? '指定';
|
||||||
|
return `欄位 [${prop}] 最多只能包含 ${num} 個項目`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const messages = errors.flatMap((e) => Object.values(e.constraints ?? {}))
|
export function validationExceptionFactory(
|
||||||
return new BadRequestException(messages.join('; '))
|
errors: ValidationError[],
|
||||||
|
): BadRequestException {
|
||||||
|
const formatErrors = (
|
||||||
|
errorList: ValidationError[],
|
||||||
|
parentProp = '',
|
||||||
|
): string[] => {
|
||||||
|
const messages: string[] = [];
|
||||||
|
|
||||||
|
for (const error of errorList) {
|
||||||
|
const fullPropPath = parentProp
|
||||||
|
? `${parentProp}.${error.property}`
|
||||||
|
: error.property;
|
||||||
|
|
||||||
|
if (error.constraints) {
|
||||||
|
Object.keys(error.constraints).forEach((key) => {
|
||||||
|
const translator = ErrorMessageMap[key];
|
||||||
|
const rawMessage = error.constraints![key];
|
||||||
|
|
||||||
|
const isDefaultMessage =
|
||||||
|
rawMessage.includes(error.property) ||
|
||||||
|
rawMessage.includes('should not') ||
|
||||||
|
rawMessage.includes('must be');
|
||||||
|
|
||||||
|
if (translator && isDefaultMessage) {
|
||||||
|
messages.push(translator(fullPropPath, rawMessage));
|
||||||
|
} else {
|
||||||
|
messages.push(rawMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.children && error.children.length > 0) {
|
||||||
|
messages.push(...formatErrors(error.children, fullPropPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allMessages = formatErrors(errors);
|
||||||
|
const uniqueMessages = Array.from(new Set(allMessages));
|
||||||
|
|
||||||
|
return new BadRequestException({
|
||||||
|
statusCode: 400,
|
||||||
|
message: uniqueMessages.length > 0 ? uniqueMessages[0] : '資料驗證失敗',
|
||||||
|
errors: uniqueMessages,
|
||||||
|
error: 'Bad Request',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
31
webpack.config.cjs
Normal file
31
webpack.config.cjs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
const webpack = require('webpack');
|
||||||
|
const nodeExternals = require('webpack-node-externals');
|
||||||
|
|
||||||
|
module.exports = function (options) {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
entry: ['./src/main.ts'],
|
||||||
|
output: {
|
||||||
|
...options.output,
|
||||||
|
filename: 'main.cjs',
|
||||||
|
},
|
||||||
|
target: 'node',
|
||||||
|
externals: [nodeExternals()],
|
||||||
|
optimization: {
|
||||||
|
splitChunks: false,
|
||||||
|
runtimeChunk: false,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
...options.resolve,
|
||||||
|
extensionAlias: {
|
||||||
|
'.js': ['.ts', '.js'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
...options.plugins,
|
||||||
|
new webpack.optimize.LimitChunkCountPlugin({
|
||||||
|
maxChunks: 1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user