Skip to content

Commit

Permalink
Merge pull request #49 from cieslarmichal/feature/multiple-files-support
Browse files Browse the repository at this point in the history
add support for multiple files upload
  • Loading branch information
cieslarmichal authored May 11, 2024
2 parents df33196 + 04786fa commit 25b0dfa
Show file tree
Hide file tree
Showing 16 changed files with 163 additions and 132 deletions.
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"inversify": "6.0.2",
"jsonwebtoken": "9.0.2",
"knex": "3.1.0",
"mime": "^4.0.3",
"node-fetch": "3.3.2",
"pino": "9.0.0",
"sharp": "0.33.3",
Expand Down
6 changes: 2 additions & 4 deletions apps/backend/src/common/types/http/httpRequest.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
/* 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;
readonly filePath: string;
}

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;
readonly files?: AttachedFile[] | undefined;
}
35 changes: 25 additions & 10 deletions apps/backend/src/core/httpRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import { TypeClone } from '@sinclair/typebox';
import { type FastifyInstance, type FastifyReply, type FastifyRequest, type FastifySchema } from 'fastify';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream';
import { promisify } from 'node:util';

import { coreSymbols } from './symbols.js';
import { ApplicationError } from '../common/errors/base/applicationError.js';
Expand All @@ -20,6 +23,8 @@ import { type LoggerService } from '../libs/logger/services/loggerService/logger
import { ForbiddenAccessError } from '../modules/authModule/application/errors/forbiddenAccessError.js';
import { UnauthorizedAccessError } from '../modules/authModule/application/errors/unathorizedAccessError.js';

const streamPipeline = promisify(pipeline);

export interface RegisterControllersPayload {
controllers: HttpController[];
}
Expand Down Expand Up @@ -82,17 +87,27 @@ export class HttpRouter {
headers: fastifyRequest.headers,
});

let attachedFile: AttachedFile | undefined;
let attachedFiles: AttachedFile[] | undefined;

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

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

const files = fastifyRequest.files();

for await (const file of files) {
const { filename, mimetype, file: data } = file;

const filePath = `/tmp/${filename}`;

const writer = createWriteStream(filePath);

await streamPipeline(data, writer);

attachedFiles.push({
name: filename,
type: mimetype,
filePath,
});
}
}

Expand All @@ -105,7 +120,7 @@ export class HttpRouter {
pathParams: fastifyRequest.params,
queryParams: fastifyRequest.query,
headers: fastifyRequest.headers as Record<string, string>,
file: attachedFile,
files: attachedFiles,
});

fastifyReply.status(statusCode);
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/core/httpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class HttpServer {
});

this.fastifyInstance.setSerializerCompiler(() => {
return (data) => JSON.stringify(data);
return (data): string => JSON.stringify(data);
});

this.addRequestPreprocessing();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ import {
} from './schemas/findUserBucketsSchema.js';
import { type ResourceMetadataDTO } from './schemas/resourceMetadataDTO.js';
import {
type UploadResourceResponseBodyDTO,
type UploadResourcePathParamsDTO,
uploadResourceResponseBodyDTOSchema,
uploadResourcePathParamsDTOSchema,
type UploadResourcesResponseBodyDTO,
type UploadResourcesPathParamsDTO,
uploadResourcesResponseBodyDTOSchema,
uploadResourcesPathParamsDTOSchema,
} from './schemas/uploadResourceSchema.js';
import { OperationNotValidError } from '../../../../../common/errors/common/operationNotValidError.js';
import { type HttpController } from '../../../../../common/types/http/httpController.js';
Expand All @@ -62,7 +62,7 @@ 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 UploadResourcesCommandHandler } from '../../../application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandler.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 @@ -77,7 +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 uploadResourceCommandHandler: UploadResourcesCommandHandler,
private readonly downloadResourcesQueryHandler: DownloadResourcesQueryHandler,
private readonly downloadImageQueryHandler: DownloadImageQueryHandler,
private readonly downloadVideoPreviewQueryHandler: DownloadVideoPreviewQueryHandler,
Expand Down Expand Up @@ -126,21 +126,21 @@ export class ResourceHttpController implements HttpController {
new HttpRoute({
method: HttpMethodName.post,
path: ':bucketName/resources',
handler: this.uploadResource.bind(this),
handler: this.uploadResources.bind(this),
schema: {
request: {
pathParams: uploadResourcePathParamsDTOSchema,
pathParams: uploadResourcesPathParamsDTOSchema,
},
response: {
[HttpStatusCode.created]: {
schema: uploadResourceResponseBodyDTOSchema,
description: 'Resource uploaded.',
schema: uploadResourcesResponseBodyDTOSchema,
description: 'Resources uploaded.',
},
},
},
securityMode: SecurityMode.bearer,
tags: ['Resource'],
description: `Upload a Resource.`,
description: `Upload Resources.`,
}),
new HttpRoute({
method: HttpMethodName.post,
Expand Down Expand Up @@ -320,29 +320,27 @@ export class ResourceHttpController implements HttpController {
};
}

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

if (!request.file) {
const files = request.files;

if (!files || files.length === 0) {
throw new OperationNotValidError({
reason: 'File is required.',
reason: 'Files are required.',
});
}

const { bucketName } = request.pathParams;

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

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

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import type * as contracts from '@common/contracts';

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

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

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

export const uploadResourceResponseBodyDTOSchema = Type.Any();
export const uploadResourcesResponseBodyDTOSchema = Type.Null();

export type UploadResourceResponseBodyDTO = Static<typeof uploadResourceResponseBodyDTOSchema>;
export type UploadResourcesResponseBodyDTO = Static<typeof uploadResourcesResponseBodyDTOSchema>;

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type CommandHandler } from '../../../../../common/types/commandHandler.js';
import { type AttachedFile } from '../../../../../common/types/http/httpRequest.js';

export interface UploadResourcesCommandHandlerPayload {
readonly userId: string;
readonly bucketName: string;
readonly files: AttachedFile[];
}

export type UploadResourcesCommandHandler = CommandHandler<UploadResourcesCommandHandlerPayload, void>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import mime from 'mime';
import { createReadStream } from 'node:fs';

import {
type UploadResourcesCommandHandler,
type UploadResourcesCommandHandlerPayload,
} from './uploadResourcesCommandHandler.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 UploadResourcesCommandHandlerImpl implements UploadResourcesCommandHandler {
public constructor(
private readonly resourceBlobSerice: ResourceBlobService,
private readonly loggerService: LoggerService,
private readonly findUserBucketsQueryHandler: FindUserBucketsQueryHandler,
) {}

public async execute(payload: UploadResourcesCommandHandlerPayload): Promise<void> {
const { userId, bucketName, files } = payload;

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

if (!buckets.includes(bucketName)) {
throw new OperationNotValidError({
reason: 'Bucket is not assigned to this user.',
userId,
bucketName,
});
}

await Promise.all(
files.map(async (file) => {
const { name: resourceName, filePath } = file;

const contentType = mime.getType(filePath);

if (!contentType) {
throw new OperationNotValidError({
reason: 'Content type not found',
resourceName,
});
}

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

const data = createReadStream(filePath);

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

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

0 comments on commit 25b0dfa

Please sign in to comment.