From 9b8e6cd9111c4864d3fd9325bbe75226b146ddc9 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Mon, 19 Aug 2024 18:09:19 +0900 Subject: [PATCH 1/8] refactor(s3, redis): Dynamic module --- src/common/database/redis/redis.module.ts | 36 +++++++++++++++---- .../database/redis/service/redis.service.ts | 13 ++----- src/common/s3/s3.module.ts | 35 ++++++++++++++---- src/common/s3/service/s3.service.ts | 15 ++------ src/domain/auth/auth.module.ts | 30 +++++----------- src/domain/auth/service/auth.service.ts | 26 +++++++------- src/domain/member/member.module.ts | 22 ++++-------- src/domain/member/service/member.service.ts | 12 +++---- .../util/scheduler/purge-unused-avatar.job.ts | 9 ++--- test/member/service.test.ts | 8 ++--- test/mock/module/auth.mock.ts | 12 +++---- test/mock/module/member.mock.ts | 6 +--- 12 files changed, 115 insertions(+), 109 deletions(-) diff --git a/src/common/database/redis/redis.module.ts b/src/common/database/redis/redis.module.ts index feede70..23d90b8 100644 --- a/src/common/database/redis/redis.module.ts +++ b/src/common/database/redis/redis.module.ts @@ -1,9 +1,31 @@ -import { Module } from '@nestjs/common'; - +import { DynamicModule, Module } from '@nestjs/common'; import { RedisService } from './service'; -@Module({ - providers: [RedisService], - exports: [RedisService], -}) -export class RedisModule {} +export interface RedisModuleOptions { + group: string; +} + +@Module({}) +export class RedisModule { + static forRoot(options: RedisModuleOptions): DynamicModule { + return { + module: RedisModule, + providers: [ + { + provide: 'REDIS_MODULE_OPTIONS_GROUP', + useValue: options.group, + }, + { + provide: `REDIS_SERVICE_${options.group.toUpperCase()}`, + useClass: RedisService, + }, + ], + exports: [ + { + provide: `REDIS_SERVICE_${options.group.toUpperCase()}`, + useClass: RedisService, + }, + ], + }; + } +} diff --git a/src/common/database/redis/service/redis.service.ts b/src/common/database/redis/service/redis.service.ts index bbb08af..f61cbfe 100644 --- a/src/common/database/redis/service/redis.service.ts +++ b/src/common/database/redis/service/redis.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -10,12 +10,12 @@ import Redis from 'ioredis'; export class RedisService { private readonly redisClient: Redis; - public group?: string; - constructor( private readonly configService: ConfigService, private readonly eventEmitter: EventEmitter2, + + @Inject('REDIS_MODULE_OPTIONS_GROUP') private readonly group: string, ) { this.redisClient = new Redis( configService.getOrThrow('redis.port'), @@ -66,11 +66,4 @@ export class RedisService { return `${this.group}:${key}`; } - - sub(group: string): RedisService { - if (this.group) throw new Error('Group already set'); - const redisService = new RedisService(this.configService, this.eventEmitter); - redisService.group = group.endsWith(':') ? group.substring(0, group.length - 1) : group; - return redisService; - } } diff --git a/src/common/s3/s3.module.ts b/src/common/s3/s3.module.ts index 7e06eb9..8725f52 100644 --- a/src/common/s3/s3.module.ts +++ b/src/common/s3/s3.module.ts @@ -1,9 +1,32 @@ -import { Module } from '@nestjs/common'; +import { DynamicModule, Module } from '@nestjs/common'; import { S3Service } from './service'; -@Module({ - providers: [S3Service], - exports: [S3Service], -}) -export class S3Module {} +export interface S3ModuleOptions { + directory: string; +} + +@Module({}) +export class S3Module { + static forRoot(options: S3ModuleOptions): DynamicModule { + return { + module: S3Module, + providers: [ + { + provide: 'S3_MODULE_OPTIONS_DIRECTORY', + useValue: options.directory, + }, + { + provide: `S3_SERVICE_${options.directory.toUpperCase()}`, + useClass: S3Service, + }, + ], + exports: [ + { + provide: `S3_SERVICE_${options.directory.toUpperCase()}`, + useClass: S3Service, + }, + ], + }; + } +} diff --git a/src/common/s3/service/s3.service.ts b/src/common/s3/service/s3.service.ts index 8c39fcb..330dea8 100644 --- a/src/common/s3/service/s3.service.ts +++ b/src/common/s3/service/s3.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -17,12 +17,12 @@ import { extname } from 'path'; export class S3Service { private readonly s3Client: S3Client; - public directory?: string; - constructor( private readonly configService: ConfigService, private readonly eventEmitter: EventEmitter2, + + @Inject('S3_MODULE_OPTIONS_DIRECTORY') private readonly directory: string, ) { this.s3Client = new S3Client({ region: configService.getOrThrow('s3.region'), @@ -91,13 +91,4 @@ export class S3Service { return url.split(`.com/`)[1].substring(this.directory.length + 1); } - - sub(directory: string): S3Service { - if (this.directory) throw new Error('Directory already set'); - const s3Service = new S3Service(this.configService, this.eventEmitter); - s3Service.directory = directory.endsWith('/') - ? directory.substring(0, directory.length - 1) - : directory; - return s3Service; - } } diff --git a/src/domain/auth/auth.module.ts b/src/domain/auth/auth.module.ts index d40825f..b639ab7 100644 --- a/src/domain/auth/auth.module.ts +++ b/src/domain/auth/auth.module.ts @@ -5,30 +5,18 @@ import { AuthService } from '@wink/auth/service'; import { MemberModule } from '@wink/member/member.module'; -import { RedisModule, RedisService } from '@wink/redis'; +import { RedisModule } from '@wink/redis'; import { MailModule } from '@wink/mail'; @Module({ - imports: [MemberModule, MailModule, RedisModule], - controllers: [AuthController], - providers: [ - AuthService, - - { - provide: `${RedisService.name}-refresh`, - useFactory: (repository: RedisService) => repository.sub('auth:refresh'), - inject: [RedisService], - }, - { - provide: `${RedisService.name}-code`, - useFactory: (repository: RedisService) => repository.sub('auth:code'), - inject: [RedisService], - }, - { - provide: `${RedisService.name}-token`, - useFactory: (repository: RedisService) => repository.sub('auth:token'), - inject: [RedisService], - }, + imports: [ + MemberModule, + MailModule, + RedisModule.forRoot({ group: 'verify_code' }), + RedisModule.forRoot({ group: 'verify_token' }), + RedisModule.forRoot({ group: 'refresh_token' }), ], + controllers: [AuthController], + providers: [AuthService], }) export class AuthModule {} diff --git a/src/domain/auth/service/auth.service.ts b/src/domain/auth/service/auth.service.ts index 888ca2e..bd227d4 100644 --- a/src/domain/auth/service/auth.service.ts +++ b/src/domain/auth/service/auth.service.ts @@ -52,9 +52,9 @@ export class AuthService { private readonly jwtService: JwtService, private readonly memberRepository: MemberRepository, - @Inject(`${RedisService.name}-refresh`) private readonly redisRefreshRepository: RedisService, - @Inject(`${RedisService.name}-code`) private readonly redisCodeRepository: RedisService, - @Inject(`${RedisService.name}-token`) private readonly redisTokenRepository: RedisService, + @Inject('REDIS_SERVICE_VERIFY_CODE') private readonly verifyCodeService: RedisService, + @Inject('REDIS_SERVICE_VERIFY_TOKEN') private readonly verifyTokenService: RedisService, + @Inject('REDIS_SERVICE_REFRESH_TOKEN') private readonly refreshTokenService: RedisService, private readonly eventEmitter: EventEmitter2, private readonly mailService: MailService, @@ -85,7 +85,7 @@ export class AuthService { const refreshToken = await this.jwtService.signAsync({}, { expiresIn: this.refreshExpiresIn }); - await this.redisRefreshRepository.ttl( + await this.refreshTokenService.ttl( refreshToken, member._id, ms(this.refreshExpiresIn as StringValue), @@ -97,11 +97,11 @@ export class AuthService { } async refresh({ refreshToken }: RefreshRequestDto): Promise { - const memberId = await this.redisRefreshRepository.get(refreshToken); + const memberId = await this.refreshTokenService.get(refreshToken); if (!memberId) { throw new InvalidRefreshTokenException(); } - await this.redisRefreshRepository.delete(refreshToken); + await this.refreshTokenService.delete(refreshToken); const member = await this.memberRepository.findById(memberId); if (!member) { @@ -118,7 +118,7 @@ export class AuthService { { expiresIn: this.refreshExpiresIn }, ); - await this.redisRefreshRepository.ttl( + await this.refreshTokenService.ttl( newRefreshToken, memberId, ms(this.refreshExpiresIn as StringValue), @@ -130,7 +130,7 @@ export class AuthService { } async register({ name, studentId, password, verifyToken }: RegisterRequestDto): Promise { - const email = await this.redisTokenRepository.get(verifyToken); + const email = await this.verifyTokenService.get(verifyToken); if (!email) { throw new InvalidVerifyTokenException(); @@ -149,7 +149,7 @@ export class AuthService { const member = await this.memberRepository.save({ name, studentId, email, password: hash }); - await this.redisTokenRepository.delete(verifyToken); + await this.verifyTokenService.delete(verifyToken); this.mailService.sendTemplate(email, new RegisterCompleteTemplate(name)).then((_) => _); @@ -165,7 +165,7 @@ export class AuthService { .toString() .padStart(6, '0'); - await this.redisCodeRepository.ttl(email, code, 60 * 10); + await this.verifyCodeService.ttl(email, code, 60 * 10); this.mailService.sendTemplate(email, new VerifyCodeTemplate(email, code)).then((_) => _); @@ -173,16 +173,16 @@ export class AuthService { } async verifyCode({ email, code }: VerifyCodeRequestDto): Promise { - const storedCode = await this.redisCodeRepository.get(email); + const storedCode = await this.verifyCodeService.get(email); if (storedCode !== code) { throw new InvalidVerifyCodeException(); } - await this.redisCodeRepository.delete(email); + await this.verifyCodeService.delete(email); const verifyToken = uuid(); - await this.redisTokenRepository.ttl(verifyToken, email, 60 * 60); + await this.verifyTokenService.ttl(verifyToken, email, 60 * 60); this.eventEmitter.emit(VerifyCodeEvent.EVENT_NAME, new VerifyCodeEvent(email, verifyToken)); diff --git a/src/domain/member/member.module.ts b/src/domain/member/member.module.ts index 12b3e1a..6304ac7 100644 --- a/src/domain/member/member.module.ts +++ b/src/domain/member/member.module.ts @@ -8,27 +8,19 @@ import { MemberRepository } from '@wink/member/repository'; import { PurgeUnusedAvatarJob } from '@wink/member/util/scheduler'; import { MongoModelFactory } from '@wink/mongo'; -import { S3Module, S3Service } from '@wink/s3'; +import { S3Module } from '@wink/s3'; import { MailModule } from '@wink/mail'; const modelFactory = MongoModelFactory.generate(Member.name, MemberSchema); @Module({ - imports: [MongooseModule.forFeatureAsync([modelFactory]), S3Module, MailModule], - controllers: [MemberController, MemberAdminController], - providers: [ - MemberService, - MemberAdminService, - MemberRepository, - - PurgeUnusedAvatarJob, - - { - provide: `${S3Service}-avatar`, - useFactory: (s3Service: S3Service) => s3Service.sub('avatar'), - inject: [S3Service], - }, + imports: [ + MongooseModule.forFeatureAsync([modelFactory]), + S3Module.forRoot({ directory: 'avatar' }), + MailModule, ], + controllers: [MemberController, MemberAdminController], + providers: [MemberService, MemberAdminService, MemberRepository, PurgeUnusedAvatarJob], exports: [MemberRepository], }) export class MemberModule {} diff --git a/src/domain/member/service/member.service.ts b/src/domain/member/service/member.service.ts index 54fa5cb..c489120 100644 --- a/src/domain/member/service/member.service.ts +++ b/src/domain/member/service/member.service.ts @@ -27,7 +27,7 @@ import * as bcrypt from 'bcrypt'; export class MemberService { constructor( private readonly memberRepository: MemberRepository, - @Inject(`${S3Service}-avatar`) private readonly s3AvatarService: S3Service, + @Inject('S3_SERVICE_AVATAR') private readonly avatarService: S3Service, private readonly eventEmitter: EventEmitter2, ) {} @@ -84,13 +84,13 @@ export class MemberService { ): Promise { const { _id: id, avatar: original } = member; - const avatar = await this.s3AvatarService.upload(file); + const avatar = await this.avatarService.upload(file); await this.memberRepository.updateAvatar(id, avatar); if (original) { - const key = this.s3AvatarService.extractKeyFromUrl(original); + const key = this.avatarService.extractKeyFromUrl(original); - await this.s3AvatarService.delete(key); + await this.avatarService.delete(key); } this.eventEmitter.emit(UpdateMyAvatarEvent.EVENT_NAME, new UpdateMyAvatarEvent(member, avatar)); @@ -102,9 +102,9 @@ export class MemberService { const { _id: id, avatar } = member; if (avatar) { - const key = this.s3AvatarService.extractKeyFromUrl(avatar); + const key = this.avatarService.extractKeyFromUrl(avatar); - await this.s3AvatarService.delete(key); + await this.avatarService.delete(key); await this.memberRepository.updateAvatar(id, null); this.eventEmitter.emit(DeleteMyAvatarEvent.EVENT_NAME, new DeleteMyAvatarEvent(member)); diff --git a/src/domain/member/util/scheduler/purge-unused-avatar.job.ts b/src/domain/member/util/scheduler/purge-unused-avatar.job.ts index d83b323..e14f8da 100644 --- a/src/domain/member/util/scheduler/purge-unused-avatar.job.ts +++ b/src/domain/member/util/scheduler/purge-unused-avatar.job.ts @@ -11,8 +11,9 @@ import { PurgeUnusedAvatarEvent } from '@wink/event'; export class PurgeUnusedAvatarJob { constructor( private readonly memberRepository: MemberRepository, + @Inject('S3_SERVICE_AVATAR') private readonly avatarService: S3Service, + private readonly eventEmitter: EventEmitter2, - @Inject(`${S3Service}-avatar`) private readonly s3AvatarService: S3Service, ) {} @Timeout(0) @@ -29,13 +30,13 @@ export class PurgeUnusedAvatarJob { const usedAvatars = (await this.memberRepository.findAll()) .map((member) => member.avatar) .filter((avatar) => avatar !== null) - .map((avatar) => this.s3AvatarService.extractKeyFromUrl(avatar)); + .map((avatar) => this.avatarService.extractKeyFromUrl(avatar)); - const savedAvatars = await this.s3AvatarService.getKeys(); + const savedAvatars = await this.avatarService.getKeys(); const unusedAvatars = savedAvatars.filter((a) => !usedAvatars.includes(a)); - unusedAvatars.forEach((key) => this.s3AvatarService.delete(key).then((_) => _)); + unusedAvatars.forEach((key) => this.avatarService.delete(key).then((_) => _)); this.eventEmitter.emit( PurgeUnusedAvatarEvent.EVENT_NAME, diff --git a/test/member/service.test.ts b/test/member/service.test.ts index 933c2ed..3c92f27 100644 --- a/test/member/service.test.ts +++ b/test/member/service.test.ts @@ -14,7 +14,7 @@ import { Readable } from 'stream'; describe('MemberService', () => { let memberService: MemberService; - let s3AvatarService: S3Service; + let avatarService: S3Service; let memoryMemberRepository: Member[]; @@ -25,7 +25,7 @@ describe('MemberService', () => { ({ memoryMemberRepository } = mock); memberService = module.get(MemberService); - s3AvatarService = module.get(`${S3Service}-avatar`); + avatarService = module.get('S3_SERVICE_AVATAR'); }); afterEach(() => { @@ -264,7 +264,7 @@ describe('MemberService', () => { // Then await expect(result).resolves.toStrictEqual({ avatar: fileUrl }); expect(memoryMemberRepository[0].avatar).toBe(fileUrl); - expect(s3AvatarService.delete).not.toHaveBeenCalled(); + expect(avatarService.delete).not.toHaveBeenCalled(); }); it('Change avatar (not first time)', async () => { @@ -298,7 +298,7 @@ describe('MemberService', () => { // Then await expect(result).resolves.toStrictEqual({ avatar: fileUrl }); expect(memoryMemberRepository[0].avatar).toBe(fileUrl); - expect(s3AvatarService.delete).toHaveBeenCalledWith(previousFileUrl); + expect(avatarService.delete).toHaveBeenCalledWith(previousFileUrl); }); }); }); diff --git a/test/mock/module/auth.mock.ts b/test/mock/module/auth.mock.ts index 16e8e5d..64903b5 100644 --- a/test/mock/module/auth.mock.ts +++ b/test/mock/module/auth.mock.ts @@ -39,17 +39,17 @@ export const mockAuth = async () => { { provide: MemberRepository, useValue: mockMemberRepository(memoryMemberRepository) }, { provide: MailService, useValue: mockMailService() }, { - provide: `${RedisService.name}-refresh`, - useValue: mockRedisService(memoryRedisRefreshRepository), - }, - { - provide: `${RedisService.name}-code`, + provide: 'REDIS_SERVICE_VERIFY_CODE', useValue: mockRedisService(memoryRedisCodeRepository), }, { - provide: `${RedisService.name}-token`, + provide: 'REDIS_SERVICE_VERIFY_TOKEN', useValue: mockRedisService(memoryRedisTokenRepository), }, + { + provide: 'REDIS_SERVICE_REFRESH_TOKEN', + useValue: mockRedisService(memoryRedisRefreshRepository), + }, ], }).compile(); diff --git a/test/mock/module/member.mock.ts b/test/mock/module/member.mock.ts index daf8800..f079e31 100644 --- a/test/mock/module/member.mock.ts +++ b/test/mock/module/member.mock.ts @@ -9,7 +9,6 @@ import { MemberRepository } from '@wink/member/repository'; import { Member } from '@wink/member/schema'; import { MemberAdminService, MemberService } from '@wink/member/service'; -import { S3Service } from '@wink/s3'; import { MailService } from '@wink/mail'; export const mockMember = async () => { @@ -29,10 +28,7 @@ export const mockMember = async () => { MemberAdminService, { provide: MemberRepository, useValue: mockMemberRepository(memoryMemberRepository) }, { provide: MailService, useValue: mockMailService() }, - { - provide: `${S3Service}-avatar`, - useValue: mockS3Service(), - }, + { provide: 'S3_SERVICE_AVATAR', useValue: mockS3Service() }, ], }).compile(); From 70d103ad140249caf603d6fcdeb8e633df747a83 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Wed, 21 Aug 2024 13:31:19 +0900 Subject: [PATCH 2/8] fix: not optional only null... --- src/domain/member/dto/response/get-members.response.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/member/dto/response/get-members.response.dto.ts b/src/domain/member/dto/response/get-members.response.dto.ts index 911e8ef..31cda27 100644 --- a/src/domain/member/dto/response/get-members.response.dto.ts +++ b/src/domain/member/dto/response/get-members.response.dto.ts @@ -39,7 +39,7 @@ export class EachGetMembersResponseDto { description: '한 줄 소개', example: '안녕하세요! 저는 개발자입니다.', }) - description?: string; + description!: string | null; @ApiProperty({ description: '링크', From b2c03feef85f330b88eb31285dd72778494020d4 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Wed, 21 Aug 2024 18:20:30 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor(config):=20jwt=20expires=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config.template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/config.template.yaml b/config/config.template.yaml index 1b70f05..fcc9a25 100644 --- a/config/config.template.yaml +++ b/config/config.template.yaml @@ -27,5 +27,5 @@ s3: jwt: secret: ${JWT_SECRET:=secret} expiresIn: - access: ${JWT_EXPIRES_IN_ACCESS:=1h} - refresh: ${JWT_EXPIRES_IN_REFRESH:=7d} + access: ${JWT_EXPIRES_IN_ACCESS:=10m} + refresh: ${JWT_EXPIRES_IN_REFRESH:=30d} From f14602edfa660f31cceff7082e8ca0d46d413cd6 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Thu, 22 Aug 2024 12:36:46 +0900 Subject: [PATCH 4/8] refactor(ignore): .gitignore & .dockerignore --- .dockerignore | 42 +----------------------------------------- .gitignore | 18 +++++++----------- 2 files changed, 8 insertions(+), 52 deletions(-) diff --git a/.dockerignore b/.dockerignore index 827bf46..3c3629e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,41 +1 @@ -# compiled output -/dist -/node_modules - -# Logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# OS -.DS_Store - -# Tests -/coverage - -# IDEs and editors -/.idea -/.vscode - -# Project specific -/config -!/config/config.template.yaml -/logs -deploy - -# Other -.git -.github -.husky - -.gitignore - -commitlint.config.js -eslint.config.mjs -jest.config.mjs -lint-staged.config.js -pretter.config.js - -LICENSE -README.md -CONTRIBUTING.md +node_modules diff --git a/.gitignore b/.gitignore index e110716..f4b8b84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # compiled output -/dist -/node_modules +dist +node_modules # Logs npm-debug.log* @@ -10,15 +10,11 @@ yarn-error.log* # OS .DS_Store -# Tests -/coverage - # IDEs and editors -/.idea -/.vscode +.idea +.vscode # Project specific -/config -!/config/config.template.yaml -/logs -deploy +config +!config/config.template.yaml +logs From c17f73e53b23c5759163070ad35eb4e1dee0929c Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Thu, 22 Aug 2024 12:39:55 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20Validation=EC=97=90=EC=84=9C=20ApiEx?= =?UTF-8?q?ception=20throw=ED=95=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../http/interceptor/api-response.interceptor.ts | 7 ++++--- src/common/util/validation/validation.util.ts | 10 ++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/common/http/interceptor/api-response.interceptor.ts b/src/common/http/interceptor/api-response.interceptor.ts index fea1a2c..80d674c 100644 --- a/src/common/http/interceptor/api-response.interceptor.ts +++ b/src/common/http/interceptor/api-response.interceptor.ts @@ -1,7 +1,6 @@ import { CallHandler, ExecutionContext, - HttpException, HttpStatus, Injectable, Logger, @@ -35,8 +34,10 @@ export class ApiResponseInterceptor implements NestInterceptor { this.logger.error(err); return throwError( () => - new HttpException('서버에서 오류가 발생했습니다.', HttpStatus.INTERNAL_SERVER_ERROR, { - cause: err, + new ApiException({ + swagger: 'Internal Server Error', + message: '서버에서 오류가 발생했습니다.', + code: HttpStatus.INTERNAL_SERVER_ERROR, }), ); } diff --git a/src/common/util/validation/validation.util.ts b/src/common/util/validation/validation.util.ts index 475d283..e82f878 100644 --- a/src/common/util/validation/validation.util.ts +++ b/src/common/util/validation/validation.util.ts @@ -1,4 +1,6 @@ -import { ArgumentMetadata, HttpException, ValidationPipe } from '@nestjs/common'; +import { ArgumentMetadata, ValidationPipe } from '@nestjs/common'; + +import { ApiException } from '@wink/swagger'; type Constraints = { [type: string]: string }; @@ -13,7 +15,11 @@ export class Validation { return new ValidationPipe({ whitelist: true, exceptionFactory: (errors) => { - return new HttpException(Object.entries(errors[0].constraints)[0][1], 400); + return new ApiException({ + swagger: 'Validation Error', + message: Object.entries(errors[0].constraints)[0][1], + code: 400, + }); }, }); } From 7a29fc7985fe7894369440017ab60c8380640a4f Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Fri, 23 Aug 2024 17:48:46 +0900 Subject: [PATCH 6/8] =?UTF-8?q?docs(README):=20docker=20registry=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5e43d00..f83d909 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -![Logo](https://avatars.githubusercontent.com/u/69004745?s=100) - # Wink Official Backend ## Tech Stack @@ -15,10 +13,9 @@ - ![mongodb](https://img.shields.io/badge/MongoDB-13aa52?style=for-the-badge&logo=mongodb&logoColor=white) - ![redis](https://img.shields.io/badge/Redis-DC382D?style=for-the-badge&logo=redis&logoColor=white) -**Convention:** -- ![eslint](https://img.shields.io/badge/ESLint-4B32C3?style=for-the-badge&logo=eslint&logoColor=white) -- ![prettier](https://img.shields.io/badge/prettier-F7B93E?style=for-the-badge&logo=prettier&logoColor=white) -- ![husky](https://img.shields.io/badge/husky-5D4F85?style=for-the-badge&logoColor=white) + +## Swagger Documentation Path +- path: (BASE_URL)/api/swagger ## Related @@ -26,11 +23,12 @@ [Wink Official Deploy](https://github.com/kmu-wink/wink-official-deploy) -## Documentation -[API Document (Master)](https://wink.kookmin.ac.kr/api/swagger) +## Live Server + +[Live Server (Master)](https://wink.kookmin.ac.kr) -[API Document (Develop)](http://43.202.208.79/api/swagger) +[Live Server (Develop)](https://wink-dev.kro.kr) ## Run Locally @@ -71,6 +69,7 @@ Start the server yarn start ``` + ## Run Locally with Docker Build the Docker image @@ -79,6 +78,16 @@ Build the Docker image docker build -t wink-official-backend:local . ``` +Or pull the Docker image from Docker Hub + +```bash +# master branch +docker pull kmuwink/wink-official-backend:master + +# develop branch +docker pull kmuwink/wink-official-backend:develop +``` + Run the Docker container ```bash @@ -100,9 +109,10 @@ docker run \ -e JWT_SECRET=(JWT_SECRET) -e JWT_EXPIRES_IN=(JWT_EXPIRES_IN) \ - -p 8080:8080 -d wink-official-backend:local + -p 8080:8080 -d (IMAGE_NAME) ``` + ## Running Tests To run tests, run the following command From 8de0b91df9eaccd2c331926c7eb76462c1867cbb Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Tue, 3 Sep 2024 11:17:52 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20Redis=20Module=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(redis): 비밀번호 추가 * refactor(redis): set에서 ttl설정하도록 로직 변경 * docs: 도커에 Redis Password 추가 * test: RedisService 수정 --- README.md | 37 ++++++++++--------- config/config.template.yaml | 1 + .../database/redis/service/redis.service.ts | 31 +++++++++++----- src/domain/auth/service/auth.service.ts | 23 +++++++----- test/mock/util/redis-service.mock.ts | 4 -- 5 files changed, 55 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index f83d909..6325703 100644 --- a/README.md +++ b/README.md @@ -92,24 +92,25 @@ Run the Docker container ```bash docker run \ - --name (CONTAINER_NAME) \ - - -e REDIS_HOST=(REDIS_HOST) -e REDIS_PORT=(REDIS_PORT) \ - - -e MONGO_HOST=(MONGO_HOST) -e MONGO_PORT=(MONGO_PORT) \ - -e MONGO_USERNAME=(MONGO_USERNAME) -e MONGO_PASSWORD=(MONGO_PASSWORD) \ - -e MONGO_AUTH_SOURCE=(MONGO_AUTH_SOURCE) -e MONGO_DATABASE=(MONGO_DATABASE) \ - - -e SMTP_HOST=(SMTP_HOST) -e SMTP_PORT=(SMTP_PORT) \ - -e SMTP_USERNAME=(SMTP_USER) -e SMTP_PASSWORD=(SMTP_PASS) \ - -e SMTP_SECURE=(SMTP_SECURE) \ - - -e S3_REGION=(S3_REGION) -e S3_BUCKET=(S3_BUCKET) \ - -e S3_ACCESS_KEY=(S3_ACCESS_KEY) -e S3_SECRET_KEY=(S3_SECRET_KEY) \ - - -e JWT_SECRET=(JWT_SECRET) -e JWT_EXPIRES_IN=(JWT_EXPIRES_IN) \ - - -p 8080:8080 -d (IMAGE_NAME) + --name (CONTAINER_NAME) \ + + -e REDIS_HOST=(REDIS_HOST) -e REDIS_PORT=(REDIS_PORT) \ + -e REDIS_PASSWORD=(REDIS_PASSWORD) \ + + -e MONGO_HOST=(MONGO_HOST) -e MONGO_PORT=(MONGO_PORT) \ + -e MONGO_USERNAME=(MONGO_USERNAME) -e MONGO_PASSWORD=(MONGO_PASSWORD) \ + -e MONGO_AUTH_SOURCE=(MONGO_AUTH_SOURCE) -e MONGO_DATABASE=(MONGO_DATABASE) \ + + -e SMTP_HOST=(SMTP_HOST) -e SMTP_PORT=(SMTP_PORT) \ + -e SMTP_USERNAME=(SMTP_USER) -e SMTP_PASSWORD=(SMTP_PASS) \ + -e SMTP_SECURE=(SMTP_SECURE) \ + + -e S3_REGION=(S3_REGION) -e S3_BUCKET=(S3_BUCKET) \ + -e S3_ACCESS_KEY=(S3_ACCESS_KEY) -e S3_SECRET_KEY=(S3_SECRET_KEY) \ + + -e JWT_SECRET=(JWT_SECRET) -e JWT_EXPIRES_IN=(JWT_EXPIRES_IN) \ + + -p 8080:8080 -d (IMAGE_NAME) ``` diff --git a/config/config.template.yaml b/config/config.template.yaml index fcc9a25..8118d3f 100644 --- a/config/config.template.yaml +++ b/config/config.template.yaml @@ -1,6 +1,7 @@ redis: host: ${REDIS_HOST:=localhost} port: ${REDIS_PORT:=6379} + password: ${REDIS_PASSWORD:=''} mongo: host: ${MONGO_HOST:=localhost} diff --git a/src/common/database/redis/service/redis.service.ts b/src/common/database/redis/service/redis.service.ts index f61cbfe..e8f6612 100644 --- a/src/common/database/redis/service/redis.service.ts +++ b/src/common/database/redis/service/redis.service.ts @@ -11,7 +11,7 @@ export class RedisService { private readonly redisClient: Redis; constructor( - private readonly configService: ConfigService, + configService: ConfigService, private readonly eventEmitter: EventEmitter2, @@ -20,35 +20,46 @@ export class RedisService { this.redisClient = new Redis( configService.getOrThrow('redis.port'), configService.getOrThrow('redis.host'), + { + password: configService.get('redis.password'), + }, ); } - async get(key: string): Promise { + async exists(key: string): Promise { if (!this.group) throw new Error('Group is not set'); const _key = this.#generateKey(key); - return this.redisClient.get(_key); + const exists = await this.redisClient.exists(_key); + + return exists === 1; } - async set(key: string, value: string): Promise { + async get(key: string): Promise { if (!this.group) throw new Error('Group is not set'); const _key = this.#generateKey(key); - await this.redisClient.set(_key, value); - - this.eventEmitter.emit(RedisSetEvent.EVENT_NAME, new RedisSetEvent(_key, value)); + return (await this.redisClient.get(_key)) || ''; } - async ttl(key: string, value: string, seconds: number): Promise { + async set(key: string, value: string, seconds: number = 0): Promise { if (!this.group) throw new Error('Group is not set'); const _key = this.#generateKey(key); - await this.redisClient.setex(_key, seconds, value); + let event: RedisSetEvent | RedisSetTtlEvent; + + if (seconds === 0) { + await this.redisClient.set(_key, value); + event = new RedisSetEvent(_key, value); + } else { + await this.redisClient.setex(_key, seconds, value); + event = new RedisSetTtlEvent(_key, value, seconds); + } - this.eventEmitter.emit(RedisSetTtlEvent.EVENT_NAME, new RedisSetTtlEvent(_key, value, seconds)); + this.eventEmitter.emit(RedisSetEvent.EVENT_NAME, event); } async delete(key: string): Promise { diff --git a/src/domain/auth/service/auth.service.ts b/src/domain/auth/service/auth.service.ts index bd227d4..0df5648 100644 --- a/src/domain/auth/service/auth.service.ts +++ b/src/domain/auth/service/auth.service.ts @@ -85,7 +85,7 @@ export class AuthService { const refreshToken = await this.jwtService.signAsync({}, { expiresIn: this.refreshExpiresIn }); - await this.refreshTokenService.ttl( + await this.refreshTokenService.set( refreshToken, member._id, ms(this.refreshExpiresIn as StringValue), @@ -97,10 +97,11 @@ export class AuthService { } async refresh({ refreshToken }: RefreshRequestDto): Promise { - const memberId = await this.refreshTokenService.get(refreshToken); - if (!memberId) { + if (!(await this.refreshTokenService.exists(refreshToken))) { throw new InvalidRefreshTokenException(); } + + const memberId = await this.refreshTokenService.get(refreshToken); await this.refreshTokenService.delete(refreshToken); const member = await this.memberRepository.findById(memberId); @@ -118,7 +119,7 @@ export class AuthService { { expiresIn: this.refreshExpiresIn }, ); - await this.refreshTokenService.ttl( + await this.refreshTokenService.set( newRefreshToken, memberId, ms(this.refreshExpiresIn as StringValue), @@ -130,12 +131,12 @@ export class AuthService { } async register({ name, studentId, password, verifyToken }: RegisterRequestDto): Promise { - const email = await this.verifyTokenService.get(verifyToken); - - if (!email) { + if (!(await this.verifyTokenService.exists(verifyToken))) { throw new InvalidVerifyTokenException(); } + const email = await this.verifyTokenService.get(verifyToken); + if (await this.memberRepository.existsByEmail(email)) { throw new AlreadyRegisteredByEmailException(); } @@ -165,7 +166,7 @@ export class AuthService { .toString() .padStart(6, '0'); - await this.verifyCodeService.ttl(email, code, 60 * 10); + await this.verifyCodeService.set(email, code, 60 * 10); this.mailService.sendTemplate(email, new VerifyCodeTemplate(email, code)).then((_) => _); @@ -173,6 +174,10 @@ export class AuthService { } async verifyCode({ email, code }: VerifyCodeRequestDto): Promise { + if (!(await this.verifyCodeService.exists(email))) { + throw new InvalidVerifyCodeException(); + } + const storedCode = await this.verifyCodeService.get(email); if (storedCode !== code) { @@ -182,7 +187,7 @@ export class AuthService { await this.verifyCodeService.delete(email); const verifyToken = uuid(); - await this.verifyTokenService.ttl(verifyToken, email, 60 * 60); + await this.verifyTokenService.set(verifyToken, email, 60 * 60); this.eventEmitter.emit(VerifyCodeEvent.EVENT_NAME, new VerifyCodeEvent(email, verifyToken)); diff --git a/test/mock/util/redis-service.mock.ts b/test/mock/util/redis-service.mock.ts index 55e53b4..13780e4 100644 --- a/test/mock/util/redis-service.mock.ts +++ b/test/mock/util/redis-service.mock.ts @@ -7,10 +7,6 @@ export const mockRedisService = (memory: Record) => ({ memory[key] = value; }), - ttl: jest.fn(async (key: string, value: string) => { - memory[key] = value; - }), - delete: jest.fn(async (key: string) => { return delete memory[key]; }), From a5173e50ae2ae20f8abe3bad07bff734f3f6e996 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Tue, 3 Sep 2024 11:54:06 +0900 Subject: [PATCH 8/8] ci: Github Workflow tnwjd (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(github): Issue Template Label 수정 * ci: 배포 workflow 수정 --- .github/ISSUE_TEMPLATE/bug_report.md | 4 +- .github/ISSUE_TEMPLATE/feature_request.md | 2 +- .github/workflows/deploy.yaml | 139 ++++++++++++++++++++++ .github/workflows/test.yml | 4 +- .github/workflows/update.yaml | 48 -------- 5 files changed, 144 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/deploy.yaml delete mode 100644 .github/workflows/update.yaml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 691641f..009de31 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,8 +1,8 @@ --- name: Bug Report about: 프로젝트의 버그와 관련된 이슈 -title: "[버그]" -labels: "🔧 Bug" +title: "[버그] " +labels: "🐞 BugFix" assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 2838029..8a199a3 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,7 @@ --- name: Feature request about: 프로젝트의 새로운 기능 추가 요청 이슈 -title: "[기능 추가]" +title: "[기능 추가] " labels: "✨ Feature" assignees: '' diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..9c4cf92 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,139 @@ +name: Deploy +run-name: Deploy (${{ github.event.workflow_run.head_branch }}) + +on: + workflow_run: + workflows: + - Test + types: + - completed + branches: + - master + - develop + +concurrency: + group: deploy-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +env: + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/wink-official-backend:${{ github.event.workflow_run.head_branch }} + + SSH_HOST: ${{ github.event.workflow_run.head_branch == 'master' && secrets.SSH_MASTER_HOST || secrets.SSH_DEVELOP_HOST }} + SSH_USERNAME: ${{ github.event.workflow_run.head_branch == 'master' && secrets.SSH_MASTER_USERNAME || secrets.SSH_DEVELOP_USERNAME }} + SSH_KEY: ${{ github.event.workflow_run.head_branch == 'master' && secrets.SSH_MASTER_KEY || secrets.SSH_DEVELOP_KEY }} + +jobs: + build-amd64: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Image + uses: docker/build-push-action@v6 + with: + push: true + platforms: linux/amd64 + provenance: false + tags: ${{ env.IMAGE_NAME }}-amd64 + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}-amd64-cache + cache-to: type=registry,ref=${{ env.IMAGE_NAME }}-amd64-cache + + build-arm64: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Image + uses: docker/build-push-action@v6 + with: + push: true + platforms: linux/arm64 + tags: ${{ env.IMAGE_NAME }}-arm64 + provenance: false + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}-arm64-cache + cache-to: type=registry,ref=${{ env.IMAGE_NAME }}-arm64-cache + + merge-image: + runs-on: ubuntu-latest + needs: [ build-amd64, build-arm64 ] + steps: + - name: Login to Docker Hub + run: | + echo "${{ secrets.DOCKERHUB_ACCESS_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Pull Backend Image + run: | + docker pull --platform linux/amd64 ${{ env.IMAGE_NAME }}-amd64 + docker pull --platform linux/arm64 ${{ env.IMAGE_NAME }}-arm64 + + - name: Merge Backend Image + run: | + docker manifest create ${{ env.IMAGE_NAME }} ${{ env.IMAGE_NAME }}-amd64 ${{ env.IMAGE_NAME }}-arm64 + docker manifest annotate ${{ env.IMAGE_NAME }} ${{ env.IMAGE_NAME }}-amd64 --os linux --arch amd64 + docker manifest annotate ${{ env.IMAGE_NAME }} ${{ env.IMAGE_NAME }}-arm64 --os linux --arch arm64 + + - name: Push Backend Image + run: docker manifest push ${{ env.IMAGE_NAME }} + + check-server: + runs-on: ubuntu-latest + steps: + - name: Check exists project + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ env.SSH_HOST }} + username: ${{ env.SSH_USERNAME }} + key: ${{ env.SSH_KEY }} + script: | + if [ ! -d "Backend" ]; then + echo "Directory "Backend" not found" + exit 1 + fi + + - name: Check docker compose file + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ env.SSH_HOST }} + username: ${{ env.SSH_USERNAME }} + key: ${{ env.SSH_KEY }} + script: | + if [ ! -f "Backend/docker-compose.yaml" ]; then + echo "File "Backend/docker-compose.yaml" not found" + exit 1 + fi + + deploy: + runs-on: ubuntu-latest + needs: [ check-server, build-amd64 ] + steps: + - name: Deploy + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ env.SSH_HOST }} + username: ${{ env.SSH_USERNAME }} + key: ${{ env.SSH_KEY }} + script: | + cd Backend + + docker compose pull + docker compose up -d diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f8640e..e1bfa30 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,8 +1,8 @@ name: Test -run-name: Test on ${{ github.ref_name }} +run-name: Test (${{ github.ref_name }}) on: - push: {} + push: concurrency: group: test-${{ github.ref_name }}-${{ github.sha }} diff --git a/.github/workflows/update.yaml b/.github/workflows/update.yaml deleted file mode 100644 index e54441d..0000000 --- a/.github/workflows/update.yaml +++ /dev/null @@ -1,48 +0,0 @@ -name: Update to Deploy Repository -run-name: Update to Deploy Repository (${{ github.event.workflow_run.head_branch }}) - -on: - workflow_run: - workflows: - - Test - types: - - completed - branches: - - master - - develop - -concurrency: - group: update-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: true - -jobs: - update-deploy: - runs-on: ubuntu-latest - steps: - - name: Set up Git - run: | - git config --global user.name 'github-actions[bot]' - git config --global user.email 'github-actions[bot]@users.noreply.github.com' - - - name: Set up Github SSH - run: | - mkdir -p ~/.ssh - echo "${{ secrets.SSH_GITHUB_KEY }}" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh-keyscan github.com >> ~/.ssh/known_hosts - - - name: Checkout code - run: | - git clone --branch ${{ github.event.workflow_run.head_branch }} --recursive git@github.com:kmu-wink/wink-official-deploy.git deploy - - - name: Update Submodule - run: | - cd deploy - git submodule update --remote backend - - - name: Commit and Push - run: | - cd deploy - git add backend - git commit -m "update: backend" - git push -f origin ${{ github.event.workflow_run.head_branch }}