Skip to content

Commit

Permalink
Merge pull request #197 from boostcampwm-2024/feature-be-#85
Browse files Browse the repository at this point in the history
Object Storage에 이미지를 업로드하는 API
  • Loading branch information
ezcolin2 authored Nov 19, 2024
2 parents eda692a + 00bb7f4 commit 7630c9e
Show file tree
Hide file tree
Showing 14 changed files with 1,381 additions and 2 deletions.
Empty file removed .vscode/settings.json
Empty file.
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.693.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.0.0",
Expand All @@ -31,6 +32,7 @@
"@nestjs/swagger": "^8.0.5",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.4.8",
"@types/multer": "^1.4.12",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"lib0": "^0.2.98",
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Node } from './node/node.entity';
import { YjsModule } from './yjs/yjs.module';
import * as path from 'path';
import { ServeStaticModule } from '@nestjs/serve-static';
import { UploadModule } from './upload/upload.module';

@Module({
imports: [
Expand All @@ -37,6 +38,7 @@ import { ServeStaticModule } from '@nestjs/serve-static';
PageModule,
EdgeModule,
YjsModule,
UploadModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/src/exception/upload.exception.ts
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(`유효하지 않은 파일입니다.`);
}
}
4 changes: 3 additions & 1 deletion apps/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filter/http-exception.filter';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { IoAdapter } from '@nestjs/platform-socket.io';
import * as express from 'express';

import * as dotenv from 'dotenv';
dotenv.config();
Expand All @@ -13,12 +14,13 @@ async function bootstrap() {
app.useWebSocketAdapter(new IoAdapter(app));
app.useGlobalFilters(new HttpExceptionFilter());
app.setGlobalPrefix('api');
app.use(express.urlencoded({ extended: true }));

const config = new DocumentBuilder()
.setTitle('OctoDocs')
.setDescription('OctoDocs API 명세서')
.build();
console.log(process.env.origin);

const documentFactory = () => SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, documentFactory);
app.enableCors({
Expand Down
18 changes: 18 additions & 0 deletions apps/backend/src/upload/dtos/imageUploadResponse.dto.ts
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;
}
20 changes: 20 additions & 0 deletions apps/backend/src/upload/s3-client.provider.ts
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],
};
21 changes: 21 additions & 0 deletions apps/backend/src/upload/upload.config.ts
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,
},
};
31 changes: 31 additions & 0 deletions apps/backend/src/upload/upload.controller.ts
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,
};
}
}
13 changes: 13 additions & 0 deletions apps/backend/src/upload/upload.module.ts
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 {}
100 changes: 100 additions & 0 deletions apps/backend/src/upload/upload.service.spec.ts
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,
);
});
});
});
27 changes: 27 additions & 0 deletions apps/backend/src/upload/upload.service.ts
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}`;
}
}
Binary file removed db
Binary file not shown.
Loading

0 comments on commit 7630c9e

Please sign in to comment.