-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #197 from boostcampwm-2024/feature-be-#85
Object Storage에 이미지를 업로드하는 API
- Loading branch information
Showing
14 changed files
with
1,381 additions
and
2 deletions.
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import { BadRequestException } from '@nestjs/common'; | ||
|
||
export class InvalidFileException extends BadRequestException { | ||
constructor() { | ||
super(`유효하지 않은 파일입니다.`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import { ApiProperty } from '@nestjs/swagger'; | ||
import { IsString } from 'class-validator'; | ||
|
||
export class ImageUploadResponseDto { | ||
@ApiProperty({ | ||
example: '이미지 업로드 성공', | ||
description: 'api 요청 결과 메시지', | ||
}) | ||
@IsString() | ||
message: string; | ||
|
||
@ApiProperty({ | ||
example: 'https://kr.object.ncloudstorage.com/octodocs-static/uploads/name', | ||
description: '업로드된 이미지 url', | ||
}) | ||
@IsString() | ||
url: string; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import { S3Client } from '@aws-sdk/client-s3'; | ||
import { Provider } from '@nestjs/common'; | ||
import { ConfigService } from '@nestjs/config'; | ||
|
||
export const S3_CLIENT = 'S3_CLIENT'; | ||
|
||
export const s3ClientProvider: Provider = { | ||
provide: S3_CLIENT, | ||
useFactory: (configService: ConfigService) => { | ||
return new S3Client({ | ||
region: configService.get('CLOUD_REGION'), | ||
endpoint: configService.get('CLOUD_ENDPOINT'), | ||
credentials: { | ||
accessKeyId: configService.get('CLOUD_ACCESS_KEY_ID'), | ||
secretAccessKey: configService.get('CLOUD_SECRET_ACCESS_KEY'), | ||
}, | ||
}); | ||
}, | ||
inject: [ConfigService], | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import { InvalidFileException } from '../exception/upload.exception'; | ||
|
||
export const MAX_FILE_SIZE = 1024 * 1024 * 5; // 5MB | ||
|
||
export const imageFileFilter = ( | ||
req: any, | ||
file: Express.Multer.File, | ||
callback: (error: Error | null, acceptFile: boolean) => void, | ||
) => { | ||
if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) { | ||
return callback(new InvalidFileException(), false); | ||
} | ||
callback(null, true); | ||
}; | ||
|
||
export const uploadOptions = { | ||
fileFilter: imageFileFilter, | ||
limits: { | ||
fileSize: MAX_FILE_SIZE, | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import { | ||
Controller, | ||
Post, | ||
UploadedFile, | ||
UseInterceptors, | ||
} from '@nestjs/common'; | ||
import { FileInterceptor } from '@nestjs/platform-express'; | ||
import { UploadService } from './upload.service'; | ||
import { uploadOptions } from './upload.config'; | ||
import { ImageUploadResponseDto } from './dtos/imageUploadResponse.dto'; | ||
|
||
export enum UploadResponseMessage { | ||
UPLOAD_IMAGE_SUCCESS = '이미지 업로드 성공', | ||
} | ||
|
||
@Controller('upload') | ||
export class UploadController { | ||
constructor(private readonly uploadService: UploadService) {} | ||
|
||
@Post('image') | ||
@UseInterceptors(FileInterceptor('file', uploadOptions)) | ||
async uploadImage( | ||
@UploadedFile() file: Express.Multer.File, | ||
): Promise<ImageUploadResponseDto> { | ||
const result = await this.uploadService.uploadImageToCloud(file); | ||
return { | ||
message: UploadResponseMessage.UPLOAD_IMAGE_SUCCESS, | ||
url: result, | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { Module } from '@nestjs/common'; | ||
import { UploadService } from './upload.service'; | ||
import { UploadController } from './upload.controller'; | ||
import { ConfigModule } from '@nestjs/config'; | ||
import { s3ClientProvider } from './s3-client.provider'; | ||
|
||
@Module({ | ||
imports: [ConfigModule], | ||
controllers: [UploadController], | ||
providers: [UploadService, s3ClientProvider], | ||
exports: [UploadService], | ||
}) | ||
export class UploadModule {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
import { Test, TestingModule } from '@nestjs/testing'; | ||
import { UploadService } from './upload.service'; | ||
import { ConfigService } from '@nestjs/config'; | ||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; | ||
import { S3_CLIENT } from './s3-client.provider'; | ||
|
||
describe('UploadService', () => { | ||
let service: UploadService; | ||
let s3Client: jest.Mocked<S3Client>; | ||
let configService: jest.Mocked<ConfigService>; | ||
|
||
beforeEach(async () => { | ||
const module: TestingModule = await Test.createTestingModule({ | ||
providers: [ | ||
UploadService, | ||
{ | ||
provide: S3_CLIENT, | ||
useValue: { | ||
send: jest.fn(), | ||
}, | ||
}, | ||
{ | ||
provide: ConfigService, | ||
useValue: { | ||
get: jest.fn(), | ||
}, | ||
}, | ||
], | ||
}).compile(); | ||
|
||
service = module.get<UploadService>(UploadService); | ||
s3Client = module.get(S3_CLIENT); | ||
configService = module.get(ConfigService); | ||
}); | ||
|
||
it('서비스 클래스가 정상적으로 인스턴스화된다.', () => { | ||
expect(service).toBeDefined(); | ||
}); | ||
|
||
describe('uploadImageToCloud', () => { | ||
it('이미지를 성공적으로 업로드하고 URL을 반환한다.', async () => { | ||
// Given | ||
const mockFile = { | ||
originalname: 'test.jpg', | ||
buffer: Buffer.from('test'), | ||
mimetype: 'image/jpeg', | ||
} as Express.Multer.File; | ||
|
||
const mockBucketName = 'test-bucket'; | ||
const mockEndpoint = 'https://test-endpoint'; | ||
|
||
jest | ||
.spyOn(configService, 'get') | ||
.mockReturnValueOnce(mockBucketName) // CLOUD_BUCKET_NAME | ||
.mockReturnValueOnce(mockEndpoint) // CLOUD_ENDPOINT | ||
.mockReturnValueOnce(mockBucketName); // CLOUD_BUCKET_NAME again | ||
|
||
jest.spyOn(s3Client, 'send').mockResolvedValue({} as never); | ||
|
||
// Mock Date.now() | ||
const mockDate = 1234567890; | ||
jest.spyOn(Date, 'now').mockReturnValue(mockDate); | ||
|
||
// When | ||
const result = await service.uploadImageToCloud(mockFile); | ||
|
||
// Then | ||
expect(s3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand)); | ||
|
||
const expectedUrl = `${mockEndpoint}/${mockBucketName}/uploads/${mockDate}-${mockFile.originalname}`; | ||
expect(result).toBe(expectedUrl); | ||
|
||
const putObjectCommand = (s3Client.send as jest.Mock).mock.calls[0][0]; | ||
expect(putObjectCommand.input).toEqual({ | ||
Bucket: mockBucketName, | ||
Key: `uploads/${mockDate}-${mockFile.originalname}`, | ||
Body: mockFile.buffer, | ||
ContentType: mockFile.mimetype, | ||
ACL: 'public-read', | ||
}); | ||
}); | ||
|
||
it('S3 업로드 실패 시 에러를 전파한다.', async () => { | ||
// Given | ||
const mockFile = { | ||
originalname: 'test.jpg', | ||
buffer: Buffer.from('test'), | ||
mimetype: 'image/jpeg', | ||
} as Express.Multer.File; | ||
|
||
const mockError = new Error('Upload failed'); | ||
jest.spyOn(s3Client, 'send').mockRejectedValue(mockError as never); | ||
|
||
// When & Then | ||
await expect(service.uploadImageToCloud(mockFile)).rejects.toThrow( | ||
mockError, | ||
); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; | ||
import { Inject, Injectable } from '@nestjs/common'; | ||
import { ConfigService } from '@nestjs/config'; | ||
import { S3_CLIENT } from './s3-client.provider'; | ||
|
||
@Injectable() | ||
export class UploadService { | ||
constructor( | ||
@Inject(S3_CLIENT) private readonly s3Client: S3Client, | ||
private readonly configService: ConfigService, | ||
) {} | ||
|
||
async uploadImageToCloud(file: Express.Multer.File) { | ||
const key = `uploads/${Date.now()}-${file.originalname}`; | ||
|
||
const command = new PutObjectCommand({ | ||
Bucket: this.configService.get('CLOUD_BUCKET_NAME'), | ||
Key: key, | ||
Body: file.buffer, | ||
ContentType: file.mimetype, | ||
ACL: 'public-read', | ||
}); | ||
|
||
await this.s3Client.send(command); | ||
return `${this.configService.get('CLOUD_ENDPOINT')}/${this.configService.get('CLOUD_BUCKET_NAME')}/${key}`; | ||
} | ||
} |
Oops, something went wrong.