Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add uploading files #21

Merged
merged 4 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.441.0",
"@aws-sdk/lib-storage": "^3.525.1",
"@common/contracts": "*",
"@fastify/cors": "^8.4.2",
"@fastify/helmet": "^11.1.1",
"@fastify/multipart": "^8.1.0",
"@fastify/swagger": "^8.12.1",
"@fastify/swagger-ui": "^1.10.2",
"@fastify/type-provider-typebox": "^3.5.0",
Expand Down
9 changes: 9 additions & 0 deletions apps/backend/src/common/types/http/httpRequest.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { type Readable } from 'node:stream';

export interface AttachedFile {
readonly name: string;
readonly type: string;
readonly data: Readable;
}

export interface HttpRequest<Body = any, QueryParams = any, PathParams = any> {
readonly body: Body;
readonly queryParams: QueryParams;
readonly pathParams: PathParams;
readonly headers: Record<string, string>;
readonly file?: AttachedFile | undefined;
}
16 changes: 16 additions & 0 deletions apps/backend/src/core/httpRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ResourceAlreadyExistsError } from '../common/errors/common/resourceAlre
import { ResourceNotFoundError } from '../common/errors/common/resourceNotFoundError.js';
import { type HttpController } from '../common/types/http/httpController.js';
import { HttpHeader } from '../common/types/http/httpHeader.js';
import { type AttachedFile } from '../common/types/http/httpRequest.js';
import { type HttpRouteSchema, type HttpRoute } from '../common/types/http/httpRoute.js';
import { HttpStatusCode } from '../common/types/http/httpStatusCode.js';
import { type DependencyInjectionContainer } from '../libs/dependencyInjection/dependencyInjectionContainer.js';
Expand Down Expand Up @@ -81,6 +82,20 @@ export class HttpRouter {
headers: fastifyRequest.headers,
});

let attachedFile: AttachedFile | undefined;

if (fastifyRequest.isMultipart()) {
const file = await fastifyRequest.file();

if (file) {
attachedFile = {
name: file.filename,
type: file.mimetype,
data: file.file,
};
}
}

const {
statusCode,
body: responseBody,
Expand All @@ -90,6 +105,7 @@ export class HttpRouter {
pathParams: fastifyRequest.params,
queryParams: fastifyRequest.query,
headers: fastifyRequest.headers as Record<string, string>,
file: attachedFile,
});

fastifyReply.status(statusCode);
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/core/httpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { fastifyCors } from '@fastify/cors';
import { fastifyHelmet } from '@fastify/helmet';
import { fastifyMultipart } from '@fastify/multipart';
import { fastifySwagger } from '@fastify/swagger';
import { fastifySwaggerUi } from '@fastify/swagger-ui';
import { type TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
Expand Down Expand Up @@ -63,6 +64,8 @@ export class HttpServer {

await this.initSwagger();

this.fastifyInstance.register(fastifyMultipart);

await this.fastifyInstance.register(fastifyHelmet);

await this.fastifyInstance.register(fastifyCors, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,29 @@ import {
type FindUserBucketsResponseBodyDTO,
} from './schemas/findUserBucketsSchema.js';
import { type ResourceMetadataDTO } from './schemas/resourceMetadataDTO.js';
import {
type UploadResourceResponseBodyDTO,
type UploadResourcePathParamsDTO,
uploadResourceResponseBodyDTOSchema,
uploadResourcePathParamsDTOSchema,
} from './schemas/uploadResourceSchema.js';
import { OperationNotValidError } from '../../../../../common/errors/common/operationNotValidError.js';
import { type HttpController } from '../../../../../common/types/http/httpController.js';
import { HttpHeader } from '../../../../../common/types/http/httpHeader.js';
import { HttpMethodName } from '../../../../../common/types/http/httpMethodName.js';
import { type HttpRequest } from '../../../../../common/types/http/httpRequest.js';
import { type HttpOkResponse, type HttpNoContentResponse } from '../../../../../common/types/http/httpResponse.js';
import {
type HttpOkResponse,
type HttpNoContentResponse,
type HttpCreatedResponse,
} from '../../../../../common/types/http/httpResponse.js';
import { HttpRoute } from '../../../../../common/types/http/httpRoute.js';
import { HttpStatusCode } from '../../../../../common/types/http/httpStatusCode.js';
import { SecurityMode } from '../../../../../common/types/http/securityMode.js';
import { type AccessControlService } from '../../../../authModule/application/services/accessControlService/accessControlService.js';
import { type FindUserBucketsQueryHandler } from '../../../../userModule/application/queryHandlers/findUserBucketsQueryHandler/findUserBucketsQueryHandler.js';
import { type DeleteResourceCommandHandler } from '../../../application/commandHandlers/deleteResourceCommandHandler/deleteResourceCommandHandler.js';
import { type UploadResourceCommandHandler } from '../../../application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandler.js';
import { type DownloadImageQueryHandler } from '../../../application/queryHandlers/downloadImageQueryHandler/downloadImageQueryHandler.js';
import { type DownloadResourceQueryHandler } from '../../../application/queryHandlers/downloadResourceQueryHandler/downloadResourceQueryHandler.js';
import { type DownloadResourcesQueryHandler } from '../../../application/queryHandlers/downloadResourcesQueryHandler/downloadResourcesQueryHandler.js';
Expand All @@ -65,6 +77,7 @@ export class ResourceHttpController implements HttpController {
private readonly deleteResourceCommandHandler: DeleteResourceCommandHandler,
private readonly findResourcesMetadataQueryHandler: FindResourcesMetadataQueryHandler,
private readonly downloadResourceQueryHandler: DownloadResourceQueryHandler,
private readonly uploadResourceCommandHandler: UploadResourceCommandHandler,
private readonly downloadResourcesQueryHandler: DownloadResourcesQueryHandler,
private readonly downloadImageQueryHandler: DownloadImageQueryHandler,
private readonly downloadVideoPreviewQueryHandler: DownloadVideoPreviewQueryHandler,
Expand Down Expand Up @@ -110,6 +123,25 @@ export class ResourceHttpController implements HttpController {
tags: ['Resource'],
description: `Find bucket's resources metadata.`,
}),
new HttpRoute({
method: HttpMethodName.post,
path: ':bucketName/resources',
handler: this.uploadResource.bind(this),
schema: {
request: {
pathParams: uploadResourcePathParamsDTOSchema,
},
response: {
[HttpStatusCode.created]: {
schema: uploadResourceResponseBodyDTOSchema,
description: 'Resource uploaded.',
},
},
},
securityMode: SecurityMode.bearer,
tags: ['Resource'],
description: `Upload a Resource.`,
}),
new HttpRoute({
method: HttpMethodName.post,
path: ':bucketName/resources/export',
Expand Down Expand Up @@ -290,6 +322,37 @@ export class ResourceHttpController implements HttpController {
};
}

private async uploadResource(
request: HttpRequest<undefined, undefined, UploadResourcePathParamsDTO>,
): Promise<HttpCreatedResponse<UploadResourceResponseBodyDTO>> {
const { userId } = await this.accessControlService.verifyBearerToken({
authorizationHeader: request.headers['authorization'],
});

if (!request.file) {
throw new OperationNotValidError({
reason: 'File is required.',
});
}

const { bucketName } = request.pathParams;

const { name, type, data } = request.file;

await this.uploadResourceCommandHandler.execute({
userId,
resourceName: name,
bucketName,
contentType: type,
data,
});

return {
statusCode: HttpStatusCode.created,
body: null,
};
}

private async downloadResource(
request: HttpRequest<undefined, undefined, DownloadResourcePathParamsDTO>,
): Promise<HttpOkResponse<unknown>> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Type, type Static } from '@sinclair/typebox';

import type * as contracts from '@common/contracts';

import { type TypeExtends } from '../../../../../../common/types/schemaExtends.js';

export const uploadResourcePathParamsDTOSchema = Type.Object({
bucketName: Type.String({ minLength: 1 }),
});

export type UploadResourcePathParamsDTO = TypeExtends<
Static<typeof uploadResourcePathParamsDTOSchema>,
contracts.UploadResourcePathParams
>;

export const uploadResourceResponseBodyDTOSchema = Type.Any();

export type UploadResourceResponseBodyDTO = Static<typeof uploadResourceResponseBodyDTOSchema>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { type Readable } from 'node:stream';

import { type CommandHandler } from '../../../../../common/types/commandHandler.js';

export interface UploadResourceCommandHandlerPayload {
readonly userId: string;
readonly resourceName: string;
readonly contentType: string;
readonly data: Readable;
readonly bucketName: string;
}

export type UploadResourceCommandHandler = CommandHandler<UploadResourceCommandHandlerPayload, void>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
type UploadResourceCommandHandler,
type UploadResourceCommandHandlerPayload,
} from './uploadResourceCommandHandler.js';
import { OperationNotValidError } from '../../../../../common/errors/common/operationNotValidError.js';
import { type LoggerService } from '../../../../../libs/logger/services/loggerService/loggerService.js';
import { type FindUserBucketsQueryHandler } from '../../../../userModule/application/queryHandlers/findUserBucketsQueryHandler/findUserBucketsQueryHandler.js';
import { type ResourceBlobService } from '../../../domain/services/resourceBlobService/resourceBlobService.js';

export class UploadResourceCommandHandlerImpl implements UploadResourceCommandHandler {
public constructor(
private readonly resourceBlobSerice: ResourceBlobService,
private readonly loggerService: LoggerService,
private readonly findUserBucketsQueryHandler: FindUserBucketsQueryHandler,
) {}

public async execute(payload: UploadResourceCommandHandlerPayload): Promise<void> {
const { userId, resourceName, bucketName, contentType, data } = payload;

const { buckets } = await this.findUserBucketsQueryHandler.execute({ userId });

if (!buckets.includes(bucketName)) {
throw new OperationNotValidError({
reason: 'Bucket does not exist.',
userId,
bucketName,
});
}

this.loggerService.debug({
message: 'Uploading Resource...',
userId,
bucketName,
resourceName,
contentType,
});

const existingResource = await this.resourceBlobSerice.resourceExists({
bucketName,
resourceName,
});

if (existingResource) {
throw new OperationNotValidError({
reason: 'Cannot create resource because it already exists.',
resourceName,
bucketName,
});
}

await this.resourceBlobSerice.uploadResource({
bucketName,
resourceName,
data,
contentType,
});

this.loggerService.debug({
message: 'Resource uploaded.',
userId,
bucketName,
resourceName,
contentType,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface UploadResourcePayload {
readonly resourceName: string;
readonly bucketName: string;
readonly data: Readable;
readonly contentType: string;
}

export interface DownloadResourcePayload {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,26 +306,6 @@ describe('ResourceBlobServiceImpl', () => {
});

describe('upload', () => {
it('throws an error - when object already exists', async () => {
const filePath = path.join(resourcesDirectory, sampleFileName1);

await s3TestUtils.uploadObject(bucketName, sampleFileName1, filePath);

try {
await resourceBlobService.uploadResource({
bucketName,
resourceName: sampleFileName1,
data: createReadStream(filePath),
});
} catch (error) {
expect(error).toBeDefined();

return;
}

expect.fail();
});

it('throws an error - when bucket does not exist', async () => {
const filePath = path.join(resourcesDirectory, sampleFileName1);

Expand All @@ -336,6 +316,7 @@ describe('ResourceBlobServiceImpl', () => {
bucketName: nonExistingBucketName,
resourceName: sampleFileName1,
data: createReadStream(filePath),
contentType: 'video/mp4',
});
} catch (error) {
expect(error).toBeDefined();
Expand All @@ -353,6 +334,7 @@ describe('ResourceBlobServiceImpl', () => {
bucketName,
resourceName: sampleFileName1,
data: createReadStream(filePath),
contentType: 'video/mp4',
});

const exists = await s3TestUtils.objectExists(bucketName, sampleFileName1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {
DeleteObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
type ListObjectsV2CommandInput,
} from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { type Readable } from 'node:stream';

import { OperationNotValidError } from '../../../../../common/errors/common/operationNotValidError.js';
Expand All @@ -30,26 +30,16 @@ export class ResourceBlobServiceImpl implements ResourceBlobService {
public async uploadResource(payload: UploadResourcePayload): Promise<void> {
const { bucketName, resourceName, data } = payload;

const exists = await this.resourceExists({
resourceName,
bucketName,
});

if (exists) {
throw new OperationNotValidError({
reason: 'Resource already exists in bucket.',
resourceName,
bucketName,
});
}

const command = new PutObjectCommand({
Bucket: bucketName,
Key: resourceName,
Body: data,
const upload = new Upload({
client: this.s3Client,
params: {
Bucket: bucketName,
Key: resourceName,
Body: data,
},
});

await this.s3Client.send(command);
await upload.done();
}

public async downloadResource(payload: DownloadResourcePayload): Promise<Resource> {
Expand Down
Loading
Loading