diff --git a/nest-cli.json b/nest-cli.json index f9aa683..e8552c2 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "plugins": ["@nestjs/swagger"] } } diff --git a/src/app.service.ts b/src/app.service.ts index c5724cb..a33588b 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -3,6 +3,6 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { getHello(): string { - return 'Hello ㅁㄴㅇㄹㅁㄴㅇㅁㄴㅇㄹ!'; + return 'Hello Davinci Code Game World!'; } } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 3b73b8c..d72667f 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,3 +1,5 @@ +// auth.controller.ts + import { Controller, Post, @@ -9,18 +11,22 @@ import { Res, Req, } from '@nestjs/common'; -import { Response } from 'express'; -import { Request } from 'express'; +import { Response, Request } from 'express'; import { JwtService } from '@nestjs/jwt'; import { UserService } from '../user/user.service'; import LoginUserDto from './dto/auth.dto'; import { RedisAuthGuard } from './auth.guard'; import { RedisService } from 'src/redis/redis.service'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBody, + ApiBearerAuth, +} from '@nestjs/swagger'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; @ApiTags('auth') - @Controller('auth') export class AuthController { constructor( @@ -30,6 +36,30 @@ export class AuthController { ) {} @Post('login') + @ApiOperation({ + summary: '로그인', + description: '이메일, 패스워드를 이용한 로그인', + }) + @ApiResponse({ + status: 200, + description: + '로그인 성공 시 Access Token(헤더), Refresh Token(쿠키)을 발급합니다.', + schema: { + example: { + accessToken: 'Bearer ', + }, + }, + }) + @ApiResponse({ + status: 401, + description: '이메일 또는 비밀번호가 잘못된 경우', + schema: { + example: { + statusCode: 401, + message: 'Invalid credentials', + }, + }, + }) async login( @Body() loginDto: LoginUserDto, @Res({ passthrough: true }) res: Response, @@ -45,6 +75,7 @@ export class AuthController { const payload = { userEmail: user.userEmail, userId: user.id }; const accessToken = this.jwtService.sign(payload, { expiresIn: '2h' }); const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' }); + await this.redisService.set(`access:${user.userEmail}`, accessToken, 3600); await this.redisService.set( `refresh:${user.userEmail}`, @@ -54,8 +85,8 @@ export class AuthController { res.cookie('refreshToken', refreshToken, { httpOnly: true, // JavaScript로 접근 불가 - secure: true, // HTTPS에서만 동작 (로컬 개발 시 false로 설정) - sameSite: 'none', // 일반 로그인 + secure: true, // HTTPS에서만 동작 (개발시엔 false로 설정) + sameSite: 'none', maxAge: 7 * 24 * 60 * 60 * 1000, // 7일 }); @@ -66,17 +97,63 @@ export class AuthController { @UseGuards(RedisAuthGuard) @Get('profile') + @ApiOperation({ + summary: '프로필 조회(Protected)', + description: '로그인이 필요한 프로필 조회 API', + }) + @ApiBearerAuth() // Swagger에서 Bearer Token 헤더를 입력할 수 있도록 표시 + @ApiResponse({ + status: 200, + description: '정상적으로 접근한 경우', + schema: { + example: { + message: 'This is a protected route', + }, + }, + }) + @ApiResponse({ + status: 401, + description: '권한이 없거나 토큰이 만료된 경우', + schema: { + example: { + statusCode: 401, + message: 'Unauthorized', + }, + }, + }) getProfile() { return { message: 'This is a protected route' }; } @Post('refresh') + @ApiOperation({ + summary: 'Access Token 갱신', + description: 'Refresh Token을 통해 새로운 Access Token을 발급받습니다.', + }) + @ApiResponse({ + status: 200, + description: '새로운 Access Token을 발급합니다.', + schema: { + example: { + accessToken: 'Bearer ', + }, + }, + }) + @ApiResponse({ + status: 401, + description: 'Refresh Token이 없거나 올바르지 않은 경우', + schema: { + example: { + statusCode: 401, + message: 'Invalid refresh token', + }, + }, + }) async refresh( @Req() req: Request, @Res({ passthrough: true }) res: Response, ) { const refreshToken = req.cookies['refreshToken']; // 쿠키에서 RefreshToken 읽기 - if (!refreshToken) { throw new HttpException( 'Refresh token not found', diff --git a/src/auth/dto/auth.dto.ts b/src/auth/dto/auth.dto.ts index 1557b09..cefa372 100644 --- a/src/auth/dto/auth.dto.ts +++ b/src/auth/dto/auth.dto.ts @@ -1,10 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsEmail, Matches, IsNotEmpty } from 'class-validator'; export default class LoginUserDto { + @ApiProperty({ + required: true, + example: 'test@email.com', + description: '이메일', + }) @IsEmail({}, { message: '이메일 형식이 틀렸습니다.' }) @IsNotEmpty() userEmail: string; + @ApiProperty({ + required: true, + example: 'teST11!!', + description: '비밀번호', + }) @IsString() @IsNotEmpty() @Matches(/^(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { diff --git a/src/gameRoom/dto/createGameRoom.dto.ts b/src/gameRoom/dto/createGameRoom.dto.ts index 70c508b..d08a290 100644 --- a/src/gameRoom/dto/createGameRoom.dto.ts +++ b/src/gameRoom/dto/createGameRoom.dto.ts @@ -1 +1,12 @@ -export class CreateGameRoomDto {} +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class CreateGameRoomDto { + @ApiProperty({ + example: 'My Awesome Room', + description: '방 이름', + }) + @IsString() + @IsNotEmpty() + roomName: string; +} diff --git a/src/gameRoom/dto/createRoomResponse.ts b/src/gameRoom/dto/createRoomResponse.ts new file mode 100644 index 0000000..e05e035 --- /dev/null +++ b/src/gameRoom/dto/createRoomResponse.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { GameRoomDto } from './gameRoom.dto'; +import { GameRoomUserDto } from './gameRoomUser.dto'; + +export class CreateRoomResponseDto { + @ApiProperty({ type: GameRoomDto }) + room: GameRoomDto; + + @ApiProperty({ type: GameRoomUserDto }) + user: GameRoomUserDto; +} diff --git a/src/gameRoom/dto/gameRoom.dto.ts b/src/gameRoom/dto/gameRoom.dto.ts index 71ce3ed..2744501 100644 --- a/src/gameRoom/dto/gameRoom.dto.ts +++ b/src/gameRoom/dto/gameRoom.dto.ts @@ -1,8 +1,25 @@ +// dto/gameRoom.dto.ts +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsString, IsDate } from 'class-validator'; -import { IsString, IsNotEmpty } from 'class-validator'; +export class GameRoomDto { + @ApiProperty({ example: 1, description: '게임 방 ID' }) + @IsNumber() + id: number; -export class GameroomDto { + @ApiProperty({ example: '테스트 방입니다.', description: '방 이름' }) @IsString() - @IsNotEmpty() - name: string; + roomName: string; + + @ApiProperty({ example: 2, description: '최대 인원 수' }) + @IsNumber() + maxPlayers: number; + + @ApiProperty({ example: 1, description: '현재 인원 수' }) + @IsNumber() + currentCount: number; + + @ApiProperty({ description: '방 생성 일시' }) + @IsDate() + createdAt: Date; } diff --git a/src/gameRoom/dto/gameRoomUser.dto.ts b/src/gameRoom/dto/gameRoomUser.dto.ts new file mode 100644 index 0000000..cfb8f60 --- /dev/null +++ b/src/gameRoom/dto/gameRoomUser.dto.ts @@ -0,0 +1,21 @@ +// dto/gameRoomUser.dto.ts +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsDate } from 'class-validator'; + +export class GameRoomUserDto { + @ApiProperty({ example: 1, description: 'GameRoomUser 테이블 PK' }) + @IsNumber() + id: number; + + @ApiProperty({ example: 10, description: '방 ID' }) + @IsNumber() + roomId: number; + + @ApiProperty({ example: 100, description: '유저 ID' }) + @IsNumber() + userId: number; + + @ApiProperty({ description: '방 참여 일시' }) + @IsDate() + joinedAt: Date; +} diff --git a/src/gameRoom/dto/getRoomStatusRespose.dto.ts b/src/gameRoom/dto/getRoomStatusRespose.dto.ts new file mode 100644 index 0000000..9724e86 --- /dev/null +++ b/src/gameRoom/dto/getRoomStatusRespose.dto.ts @@ -0,0 +1,12 @@ +// dto/getRoomStatusResponse.dto.ts +import { ApiProperty } from '@nestjs/swagger'; +import { GameRoomDto } from './gameRoom.dto'; +import { GameRoomUserDto } from './gameRoomUser.dto'; + +export class GetRoomStatusResponseDto { + @ApiProperty({ type: GameRoomDto }) + roomName: GameRoomDto; + + @ApiProperty({ type: [GameRoomUserDto] }) + users: GameRoomUserDto[]; +} diff --git a/src/gameRoom/dto/updateGameRoom.dto.ts b/src/gameRoom/dto/updateGameRoom.dto.ts deleted file mode 100644 index 88ccac0..0000000 --- a/src/gameRoom/dto/updateGameRoom.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateGameRoomDto } from './createGameRoom.dto'; - -export class UpdateGameRoomDto extends PartialType(CreateGameRoomDto) {} diff --git a/src/gameRoom/gameRoom.controller.ts b/src/gameRoom/gameRoom.controller.ts index b4494f9..730b696 100644 --- a/src/gameRoom/gameRoom.controller.ts +++ b/src/gameRoom/gameRoom.controller.ts @@ -1,3 +1,4 @@ +// gameRoom.controller.ts import { Controller, Post, @@ -7,45 +8,201 @@ import { Param, UseGuards, Req, + BadRequestException, + NotFoundException, } from '@nestjs/common'; import { GameRoomService } from './gameRoom.service'; import { RedisAuthGuard } from 'src/auth/auth.guard'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBody, + ApiParam, +} from '@nestjs/swagger'; + +// DTO import +import { CreateGameRoomDto } from './dto/createGameRoom.dto'; +import { CreateRoomResponseDto } from './dto/createRoomResponse'; +import { GameRoomUserDto } from './dto/gameRoomUser.dto'; +import { GetRoomStatusResponseDto } from './dto/getRoomStatusRespose.dto'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; @ApiTags('gameRoom') - @Controller('gameRoom') -@UseGuards(RedisAuthGuard) // 컨트롤러 전체에 Guard 적용 export class GameRoomController { constructor(private readonly gameRoomService: GameRoomService) {} - // 방 생성과 동시에 참가 + @Get('all') + @ApiOperation({ summary: '전체 방 리스트 조회' }) + @ApiResponse({ + status: 200, + description: '전체 방 리스트를 배열 형태로 반환', + schema: { + example: [ + { + id: 1, + roomName: '테스트방1', + maxPlayers: 2, + currentCount: 1, + createdAt: '2025-01-02T10:00:00.000Z', + }, + { + id: 2, + roomName: '테스트방2', + maxPlayers: 2, + currentCount: 2, + createdAt: '2025-01-02T11:00:00.000Z', + }, + ], + }, + }) + async getAllRooms() { + return await this.gameRoomService.getAllRooms(); + } + @UseGuards(RedisAuthGuard) // 컨트롤러 전체에 Guard 적용 @Post('create') - async createRoom(@Body() body: { roomName: string }, @Req() req: any) { - const { roomName } = body; - const userId = await req.user.userId; // JWT에서 추출한 userId 사용 - - return await this.gameRoomService.createRoom(roomName, userId); + @ApiOperation({ summary: '방 생성과 동시에 참가' }) + @ApiBody({ type: CreateGameRoomDto }) + @ApiResponse({ + status: 201, + description: + '방이 성공적으로 생성되고, 방에 참여한 유저 정보를 반환합니다.', + type: CreateRoomResponseDto, + }) + @ApiResponse({ + status: 400, + description: '이미 다른 방에 참여 중이거나, 기타 형식 에러', + schema: { + example: { + statusCode: 400, + message: 'User 100 is already in room 1. Please leave that room first.', + }, + }, + }) + async createRoom(@Body() body: CreateGameRoomDto, @Req() req: any) { + try { + const userId = req.user.userId; // JWT에서 추출한 userId + return await this.gameRoomService.createRoom(body.roomName, userId); + } catch (error) { + throw new BadRequestException(error.message); + } } - // 방 참가 + @UseGuards(RedisAuthGuard) // 컨트롤러 전체에 Guard 적용 @Post('join/:roomId') + @ApiOperation({ summary: '방 참가' }) + @ApiParam({ name: 'roomId', type: Number, description: '참가할 게임 방 ID' }) + @ApiResponse({ + status: 201, + description: + '해당 방에 정상적으로 참가했다면, GameRoomUser 정보를 반환합니다.', + type: GameRoomUserDto, + }) + @ApiResponse({ + status: 400, + description: + '이미 다른 방에 참여 중이거나, 방이 꽉 찼거나, 이미 같은 방에 참가 중', + schema: { + example: { + statusCode: 400, + message: 'Room is full', + }, + }, + }) + @ApiResponse({ + status: 404, + description: '방을 찾을 수 없음', + schema: { + example: { + statusCode: 404, + message: 'Room not found', + }, + }, + }) async joinRoom(@Param('roomId') roomId: number, @Req() req: any) { - const userId = req.user.userId; // JWT에서 추출한 userId 사용 - return await this.gameRoomService.joinRoom(roomId, userId); + try { + const userId = req.user.userId; // JWT에서 추출한 userId + return await this.gameRoomService.joinRoom(roomId, userId); + } catch (error) { + if (error instanceof NotFoundException) { + throw new NotFoundException(error.message); + } + throw new BadRequestException(error.message); + } } - // 방 나가기 + @UseGuards(RedisAuthGuard) // 컨트롤러 전체에 Guard 적용 @Delete('leave/:roomId') + @ApiOperation({ summary: '방 나가기' }) + @ApiParam({ name: 'roomId', type: Number, description: '나갈 게임 방 ID' }) + @ApiResponse({ + status: 200, + description: '방에서 정상적으로 나갔을 경우 메시지를 반환합니다.', + schema: { + example: { + message: 'User 100 left room 1', + }, + }, + }) + @ApiResponse({ + status: 400, + description: '유저가 방에 없거나 기타 오류', + schema: { + example: { + statusCode: 400, + message: 'User not in the room', + }, + }, + }) + @ApiResponse({ + status: 404, + description: '방을 찾을 수 없음', + schema: { + example: { + statusCode: 404, + message: 'Room not found', + }, + }, + }) async leaveRoom(@Param('roomId') roomId: number, @Req() req: any) { - const userId = req.user.userId; // JWT에서 추출한 userId 사용 - return await this.gameRoomService.leaveRoom(roomId, userId); + try { + const userId = req.user.userId; // JWT에서 추출한 userId + return await this.gameRoomService.leaveRoom(roomId, userId); + } catch (error) { + if (error instanceof NotFoundException) { + throw new NotFoundException(error.message); + } + throw new BadRequestException(error.message); + } } - // 방 상태 조회 @Get(':roomId') + @ApiOperation({ summary: '방 상태 조회' }) + @ApiParam({ name: 'roomId', type: Number, description: '조회할 게임 방 ID' }) + @ApiResponse({ + status: 200, + description: '해당 방과 방에 속한 유저 목록을 반환합니다.', + type: GetRoomStatusResponseDto, + }) + @ApiResponse({ + status: 404, + description: '방을 찾을 수 없음', + schema: { + example: { + statusCode: 404, + message: 'Room not found', + }, + }, + }) async getRoomStatus(@Param('roomId') roomId: number) { - return await this.gameRoomService.getRoomStatus(roomId); + try { + return await this.gameRoomService.getRoomStatus(roomId); + } catch (error) { + if (error instanceof NotFoundException) { + throw new NotFoundException(error.message); + } + throw error; + } } } diff --git a/src/gameRoom/gameRoom.service.ts b/src/gameRoom/gameRoom.service.ts index 9788402..06962e3 100644 --- a/src/gameRoom/gameRoom.service.ts +++ b/src/gameRoom/gameRoom.service.ts @@ -18,7 +18,10 @@ export class GameRoomService { private readonly gameRoomUserRepository: Repository, ) {} - // 방 생성과 동시에 유저 참가 + async getAllRooms(): Promise { + return this.gameRoomRepository.find(); + } + async createRoom(roomName: string, userId: number) { // 1. 유저가 이미 어떤 방에 속해 있는지 확인 const existingMembership = await this.gameRoomUserRepository.findOne({ diff --git a/src/main.ts b/src/main.ts index 60c0913..2cc0e07 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,8 +15,17 @@ async function bootstrap() { .setTitle('API Documentation') .setDescription('The API description') .setVersion('1.0') - .addTag('auth') // 원하는 태그를 추가 + .addBearerAuth( + // Swagger에 Bearer 토큰 설정 추가 + { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', // JWT 사용 시 명시 + }, + 'access-token', // 키 이름 (선택 사항) + ) .build(); + const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api', app, document); diff --git a/src/user/dto/checkEmail.dto.ts b/src/user/dto/checkEmail.dto.ts new file mode 100644 index 0000000..e534757 --- /dev/null +++ b/src/user/dto/checkEmail.dto.ts @@ -0,0 +1,6 @@ +import { PickType } from '@nestjs/swagger'; +import SingupUserDto from './user.dto'; + +export class CheckEmailDto extends PickType(SingupUserDto, [ + 'userEmail', +] as const) {} diff --git a/src/user/dto/user.dto.ts b/src/user/dto/user.dto.ts index f8922f7..5b78299 100644 --- a/src/user/dto/user.dto.ts +++ b/src/user/dto/user.dto.ts @@ -1,14 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsEmail, Matches, IsNotEmpty } from 'class-validator'; export default class SingupUserDto { + @ApiProperty({ + required: true, + example: 'test@email.com', + description: '이메일', + }) @IsEmail({}, { message: '이메일 형식이 틀렸습니다.' }) @IsNotEmpty() userEmail: string; + @ApiProperty({ + required: true, + example: 'exampleNickname', + description: '닉네임', + }) @IsString() @IsNotEmpty() userNickname: string; + @ApiProperty({ + required: true, + example: 'teST11!!', + description: '비밀번호', + }) @IsString() @IsNotEmpty() @Matches(/^(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index c115575..3ce3b5d 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -1,15 +1,43 @@ -import { Body, Controller, Post, BadRequestException } from '@nestjs/common'; +import { + Body, + Controller, + Post, + BadRequestException, + HttpCode, +} from '@nestjs/common'; import { UserService } from './user.service'; import SingupUserDto from './dto/user.dto'; import { validateOrReject } from 'class-validator'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { CheckEmailDto } from './dto/checkEmail.dto'; + @ApiTags('user') @Controller('user') export class UserController { constructor(private readonly userService: UserService) {} @Post('signup') + @ApiOperation({ summary: '회원 가입' }) + @ApiResponse({ + status: 201, + description: '회원 가입 성공', + schema: { + example: { + message: 'User created successfully', + }, + }, + }) + @ApiResponse({ + status: 400, + description: '회원 가입 실패(중복/형식 오류)', + schema: { + example: { + status: 400, + message: 'Failed to create user', + }, + }, + }) async signup( @Body() signupData: SingupUserDto, ): Promise<{ message: string }> { @@ -28,15 +56,38 @@ export class UserController { } } - @Post('email/check') async checkEmail( - @Body('userEmail') userEmail: string, + @Post('email/check') + @HttpCode(200) + @ApiOperation({ summary: '이메일 중복 체크' }) + @ApiResponse({ + status: 200, + description: '이메일 중복 체크 결과 반환', + schema: { + example: { + available: true, + }, + }, + }) + @ApiResponse({ + status: 400, + description: '이메일 형식 오류 또는 체크 실패', + schema: { + example: { + status: 400, + message: 'Invalid email format or failed to check email', + }, + }, + }) + async checkEmail( + @Body() checkEmailDto: CheckEmailDto, ): Promise<{ available: boolean }> { try { - const dto = new SingupUserDto(); - dto.userEmail = userEmail; - await validateOrReject(dto); + // validateOrReject로 DTO에 선언된 Validation 검사 + await validateOrReject(checkEmailDto); - const existingUser = await this.userService.findEmailDplct(userEmail); + const existingUser = await this.userService.findEmailDplct( + checkEmailDto.userEmail, + ); if (existingUser) { return { available: false }; } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 1e82513..d901a91 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -45,10 +45,10 @@ export class UserService { } async validateUser(userEmail: string, pass: string): Promise { - const user = await this.findEmailDplct(userEmail); // 이메일로 유저 검색 + const user = await this.findEmailDplct(userEmail); if (user && (await bcrypt.compare(pass, user.password))) { - return user; // 유저가 존재하고 비밀번호가 일치하면 유저 반환 + return user; } - return null; // 검증 실패 + return null; } }