Skip to content

Commit

Permalink
partial implementation of upload feature to cloudflare r2
Browse files Browse the repository at this point in the history
  • Loading branch information
dan-santos committed Oct 21, 2023
1 parent 0497e97 commit 0adf80d
Show file tree
Hide file tree
Showing 22 changed files with 3,135 additions and 191 deletions.
2,923 changes: 2,741 additions & 182 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"test:e2e:watch": "vitest --config ./vitest.config.e2e.ts --dir src/"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.433.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
Expand All @@ -45,6 +46,7 @@
"@swc/core": "^1.3.93",
"@types/bcryptjs": "^2.4.4",
"@types/express": "^4.17.17",
"@types/multer": "^1.4.9",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^3.0.10",
"@types/supertest": "^2.0.14",
Expand Down
6 changes: 6 additions & 0 deletions src/core/errors/custom-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@ export class WrongCredentialsError extends Error implements UseCaseError {
constructor() {
super('Wrong Credentials');
}
}

export class InvalidAttachmentTypeError extends Error implements UseCaseError {
constructor() {
super('File type is not valid');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Attachment } from '../../enterprise/entities/attachment';

export abstract class IAttachmentsRepository {
abstract create(attachment: Attachment): Promise<void>;
}
9 changes: 9 additions & 0 deletions src/domain/forum/application/storage/uploader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface UploadParams {
fileName: string;
fileType: string;
body: Buffer;
}

export abstract class Uploader {
abstract upload(params: UploadParams): Promise<{ url: string }>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { InMemoryAttachmentsRepository } from 'test/repositories/in-memory-attachments-repository';
import { UploadAndCreateAttachmentUseCase } from './upload-and-create-attachment';
import { FakeUploader } from 'test/storage/fake-uploader';
import { InvalidAttachmentTypeError } from '@/core/errors/custom-errors';

let repository: InMemoryAttachmentsRepository;
let uploader: FakeUploader;
let sut: UploadAndCreateAttachmentUseCase;

describe('Upload and create attachment tests', () => {
beforeEach(() => {
repository = new InMemoryAttachmentsRepository();
uploader = new FakeUploader();
sut = new UploadAndCreateAttachmentUseCase(repository, uploader);
});

it('should be able to upload a new attachment', async () => {
const result = await sut.execute({
fileName: 'document.png',
fileType: 'image/png',
body: Buffer.from('')
});

expect(result.isRight()).toBe(true);
expect(result.value).toEqual({
attachment: repository.items[0],
});
expect(uploader.uploads).toHaveLength(1);
expect(uploader.uploads[0]).toEqual(
expect.objectContaining({
fileName: 'document.png'
}),
);
});

it('should NOT be able to upload a new attachment with wrong mime type', async () => {
const result = await sut.execute({
fileName: 'document.txt',
fileType: 'text/plain',
body: Buffer.from('')
});

expect(result.isLeft()).toBe(true);
expect(result.value).toBeInstanceOf(InvalidAttachmentTypeError);
expect(uploader.uploads).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Either, left, right } from '@/core/either';
import { Injectable } from '@nestjs/common';
import { InvalidAttachmentTypeError } from '@/core/errors/custom-errors';
import { Attachment } from '../../enterprise/entities/attachment';
import { IAttachmentsRepository } from '../repositories/attachments-repository';
import { Uploader } from '../storage/uploader';

interface UploadAndCreateAttachmentUseCaseRequest {
fileName: string;
fileType: string;
body: Buffer;
}

type UploadAndCreateAttachmentUseCaseResponse = Either<
InvalidAttachmentTypeError,
{
attachment: Attachment
}
>;

@Injectable()
export class UploadAndCreateAttachmentUseCase {
constructor(
private attachmentsRepository: IAttachmentsRepository,
private uploader: Uploader
){}

async execute({
fileName,
fileType,
body
}: UploadAndCreateAttachmentUseCaseRequest): Promise<UploadAndCreateAttachmentUseCaseResponse> {
if (!/^(image\/(jpeg|png))$|^application\/pdf$/.test(fileType)) {
return left(new InvalidAttachmentTypeError());
}

const { url } = await this.uploader.upload({
fileName,
fileType,
body
});

const attachment = Attachment.create({
title: fileName,
url,
});

await this.attachmentsRepository.create(attachment);

return right({ attachment });
}
}
6 changes: 3 additions & 3 deletions src/domain/forum/enterprise/entities/attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ import { UniqueEntityID } from '@/core/entities/unique-entity-id';

interface AttachmentProps {
title: string;
link: string;
url: string;
}

export class Attachment extends Entity<AttachmentProps>{
get title() {
return this.props.title;
}

get link() {
return this.props.link;
get url() {
return this.props.url;
}

static create(props: AttachmentProps, id?: UniqueEntityID){
Expand Down
6 changes: 5 additions & 1 deletion src/infra/database/database.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { IAnswerCommentsRepository } from '@forum-repositories/answer-comments-r
import { IAnswersRepository } from '@forum-repositories/answers-repository';
import { IQuestionCommentsRepository } from '@forum-repositories/question-comments-repository';
import { IQuestionAttachmentsRepository } from '@forum-repositories/question-attachments-repository';
import { IAttachmentsRepository } from '@/domain/forum/application/repositories/attachments-repository';
import { PrismaAttachmentsRepository } from './prisma/repositories/prisma-attachments-repository';

@Module({
providers: [
Expand All @@ -24,7 +26,8 @@ import { IQuestionAttachmentsRepository } from '@forum-repositories/question-att
{ provide: IQuestionAttachmentsRepository, useClass: PrismaQuestionAttachmentsRepository },
{ provide: IQuestionCommentsRepository, useClass: PrismaQuestionCommentsRepository },
{ provide: IQuestionsRepository, useClass: PrismaQuestionsRepository },
{ provide: IStudentsRepository, useClass: PrismaStudentsRepository }
{ provide: IStudentsRepository, useClass: PrismaStudentsRepository },
{ provide: IAttachmentsRepository, useClass: PrismaAttachmentsRepository }
],
exports: [
PrismaService,
Expand All @@ -35,6 +38,7 @@ import { IQuestionAttachmentsRepository } from '@forum-repositories/question-att
IQuestionCommentsRepository,
IQuestionsRepository,
IStudentsRepository,
IAttachmentsRepository
]
})
export class DatabaseModule {}
12 changes: 12 additions & 0 deletions src/infra/database/prisma/mappers/prisma-attachments-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Attachment } from '@forum-entities/attachment';
import { Prisma } from '@prisma/client';

export class PrismaAttachmentsMapper {
static toDatabase(raw: Attachment): Prisma.AttachmentUncheckedCreateInput {
return {
id: raw.id.toString(),
title: raw.title,
url: raw.url
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { IAttachmentsRepository } from '@forum-repositories/attachments-repository';
import { Attachment } from '@forum-entities/attachment';
import { PrismaService } from '../prisma.service';
import { PrismaAttachmentsMapper } from '../mappers/prisma-attachments-mapper';
import { Injectable } from '@nestjs/common';

@Injectable()
export class PrismaAttachmentsRepository implements IAttachmentsRepository {
constructor(
private prisma: PrismaService
){}

async create(attachment: Attachment): Promise<void> {
const data = PrismaAttachmentsMapper.toDatabase(attachment);

await this.prisma.attachment.create({
data
});
}
}
4 changes: 4 additions & 0 deletions src/infra/env/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const envSchema = z.object({
PORT: z.coerce.number().optional().default(3000),
JWT_PRIVATE_KEY: z.string(),
JWT_PUBLIC_KEY: z.string(),
CLOUDFLARE_ACCOUNT_ID: z.string(),
AWS_BUCKET_NAME: z.string(),
AWS_ACCESS_KEY_ID: z.string(),
AWS_SECRET_ACCESS_KEY: z.string()
});

export type Env = z.infer<typeof envSchema>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { AppModule } from '@/infra/app.module';
import { DatabaseModule } from '@/infra/database/database.module';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { StudentFactory } from 'test/factories/make-student';

describe('[e2e] upload attachment tests', () => {
let app: INestApplication;
let jwt: JwtService;
let studentFactory: StudentFactory;

beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, DatabaseModule],
providers: [StudentFactory],
}).compile();

app = moduleRef.createNestApplication();
jwt = moduleRef.get(JwtService);
studentFactory = moduleRef.get(StudentFactory);

await app.init();
});

it('[POST] /attachments', async () => {
const user = await studentFactory.makePrismaStudent();

const accessToken = jwt.sign({ sub: user.id.toString() });

const response = await request(app.getHttpServer())
.post('/attachments')
.set('Authorization', `Bearer ${accessToken}`)
.attach('file', './test/e2e/document.pdf');

expect(response.statusCode).toBe(201);
expect(response.body).toEqual({
attachmentId: expect.any(String)
});
});
});
63 changes: 63 additions & 0 deletions src/infra/http/controllers/upload-attachment.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { InvalidAttachmentTypeError } from '@/core/errors/custom-errors';
import { UploadAndCreateAttachmentUseCase } from '@forum-use-cases/upload-and-create-attachment';
import {
BadRequestException,
Controller,
FileTypeValidator,
HttpCode,
MaxFileSizeValidator,
ParseFilePipe,
Post,
UnsupportedMediaTypeException,
UploadedFile,
UseInterceptors
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

@Controller('/attachments')
export class UploadAttachmentController {

constructor(
private uploadAndCreateAttachment: UploadAndCreateAttachmentUseCase
){}

@Post()
@HttpCode(201)
@UseInterceptors(FileInterceptor('file'))
async handle (
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({
maxSize: 1024 * 1024 * 2 //2mb
}),
new FileTypeValidator({ fileType: '.(png|jpg|jpeg|pdf)' }),
],
}),
)
file: Express.Multer.File
){
const result = await this.uploadAndCreateAttachment.execute({
fileName: file.originalname,
fileType: file.mimetype,
body: file.buffer
});

if (result.isLeft()) {
const error = result.value;

switch (error.constructor) {
case InvalidAttachmentTypeError:
throw new UnsupportedMediaTypeException(error.message);
default:
throw new BadRequestException(error.message);
}
}

const { attachment } = result.value;

return {
attachmentId: attachment.id.toString()
};
}
}
12 changes: 9 additions & 3 deletions src/infra/http/http.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ import { FetchQuestionCommentsController } from './controllers/fetch-question-co
import { FetchQuestionCommentsUseCase } from '@/domain/forum/application/use-cases/fetch-question-comments';
import { FetchAnswerCommentsUseCase } from '@/domain/forum/application/use-cases/fetch-answer-comments';
import { FetchAnswerCommentsController } from './controllers/fetch-answer-comments.controller';
import { UploadAttachmentController } from './controllers/upload-attachment.controller';
import { StorageModule } from '../storage/storage.module';
import { UploadAndCreateAttachmentUseCase } from '@/domain/forum/application/use-cases/upload-and-create-attachment';

@Module({
controllers: [
Expand All @@ -57,7 +60,8 @@ import { FetchAnswerCommentsController } from './controllers/fetch-answer-commen
DeleteQuestionCommentController,
DeleteAnswerCommentController,
FetchQuestionCommentsController,
FetchAnswerCommentsController
FetchAnswerCommentsController,
UploadAttachmentController
],
providers: [
CreateQuestionUseCase,
Expand All @@ -77,11 +81,13 @@ import { FetchAnswerCommentsController } from './controllers/fetch-answer-commen
DeleteQuestionCommentUseCase,
DeleteAnswerCommentUseCase,
FetchQuestionCommentsUseCase,
FetchAnswerCommentsUseCase
FetchAnswerCommentsUseCase,
UploadAndCreateAttachmentUseCase
],
imports: [
DatabaseModule,
CryptographyModule
CryptographyModule,
StorageModule
]
})
export class HttpModule {}
Loading

0 comments on commit 0adf80d

Please sign in to comment.