Skip to content

Commit

Permalink
feat: add a function to check existing file on s3 bucket (#496)
Browse files Browse the repository at this point in the history
* feat: add a function to check existing file on s3 bucket

* fix: update hasFileInBucket method

* feat: add suffix name

* fix: add value into the constant.ts

* feat: add get list object to add suffixx

* fix: update duplicate file name handling

* fix: update method name

* fix: update suffix generation logic

* fix: update type name

* fix: update failsafemechanism strategy

* fix: update method name

* fix: update method name
  • Loading branch information
surajrai-dzango committed Sep 12, 2023
1 parent 285aa52 commit e6ad3e6
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 11 deletions.
13 changes: 12 additions & 1 deletion packages/s3/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,15 @@ const TABLE_FILES = "files";
const BUCKET_FROM_OPTIONS = "optionsBucket";
const BUCKET_FROM_FILE_FIELDS = "fileFieldsBucket";

export { BUCKET_FROM_FILE_FIELDS, BUCKET_FROM_OPTIONS, TABLE_FILES };
const OVERRIDE = "override";
const ADD_SUFFIX = "add-suffix";
const ERROR = "error";

export {
ADD_SUFFIX,
BUCKET_FROM_FILE_FIELDS,
BUCKET_FROM_OPTIONS,
ERROR,
OVERRIDE,
TABLE_FILES,
};
43 changes: 38 additions & 5 deletions packages/s3/src/model/files/service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { BaseService } from "@dzangolab/fastify-slonik";
import { v4 as uuidv4 } from "uuid";

import { TABLE_FILES } from "../../constants";
import { ADD_SUFFIX, ERROR, OVERRIDE, TABLE_FILES } from "../../constants";
import { PresignedUrlOptions, FilePayload } from "../../types/";
import { getPreferredBucket, getFileExtension } from "../../utils";
import {
getPreferredBucket,
getFileExtension,
getFilenameWithSuffix,
getBaseName,
} from "../../utils";
import S3Client from "../../utils/s3Client";

import type { Service } from "@dzangolab/fastify-slonik";
Expand Down Expand Up @@ -110,7 +115,12 @@ class FileService<
upload = async (data: FilePayload) => {
const { fileContent, fileFields } = data.file;
const { filename, mimetype, data: fileData } = fileContent;
const { path = "", bucket = "", bucketChoice } = data.options || {};
const {
path = "",
bucket = "",
bucketChoice,
filenameResolutionStrategy = OVERRIDE,
} = data.options || {};

const fileExtension = getFileExtension(filename);
this.fileExtension = fileExtension;
Expand All @@ -119,8 +129,31 @@ class FileService<
this.s3Client.bucket =
getPreferredBucket(bucket, fileFields?.bucket, bucketChoice) || "";

const key = this.key;

let key = this.key;

// check file exist
const headObjectResponse = await this.s3Client.isFileExists(key);

if (headObjectResponse) {
switch (filenameResolutionStrategy) {
case ERROR: {
throw new Error("File already exists in S3.");
}
case ADD_SUFFIX: {
const baseFilename = getBaseName(this.filename);
const listObjects = await this.s3Client.getObjects(baseFilename);

const filenameWithSuffix = getFilenameWithSuffix(
listObjects,
baseFilename,
this.fileExtension
);
this.filename = filenameWithSuffix;
key = this.key;
break;
}
}
}
const uploadResult = await this.s3Client.upload(fileData, key, mimetype);

if (!uploadResult) {
Expand Down
13 changes: 12 additions & 1 deletion packages/s3/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { FileCreateInput } from "./file";
import { BUCKET_FROM_FILE_FIELDS, BUCKET_FROM_OPTIONS } from "../constants";
import {
ADD_SUFFIX,
BUCKET_FROM_FILE_FIELDS,
BUCKET_FROM_OPTIONS,
ERROR,
OVERRIDE,
} from "../constants";

type BucketChoice = typeof BUCKET_FROM_FILE_FIELDS | typeof BUCKET_FROM_OPTIONS;
type FilenameResolutionStrategy =
| typeof ADD_SUFFIX
| typeof ERROR
| typeof OVERRIDE;

interface BaseOption {
bucket?: string;
Expand All @@ -13,6 +23,7 @@ interface PresignedUrlOptions extends BaseOption {

interface FilePayloadOptions extends BaseOption {
bucketChoice?: BucketChoice;
filenameResolutionStrategy?: FilenameResolutionStrategy;
path?: string;
}

Expand Down
40 changes: 39 additions & 1 deletion packages/s3/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { ListObjectsOutput } from "@aws-sdk/client-s3";

import { BUCKET_FROM_FILE_FIELDS, BUCKET_FROM_OPTIONS } from "../constants";
import { BucketChoice } from "../types";

const getBaseName = (filename: string): string => {
let baseName = filename.replace(/\.[^.]+$/, "");

baseName = baseName.replace(/-\d+$/, "");

return baseName;
};

const getFileExtension = (filename: string): string => {
const lastDotIndex = filename.lastIndexOf(".");

Expand Down Expand Up @@ -35,4 +45,32 @@ const getPreferredBucket = (
return fileFieldsBucket || optionsBucket;
};

export { getFileExtension, getPreferredBucket };
const getFilenameWithSuffix = (
listObjects: ListObjectsOutput,
baseFilename: string,
fileExtension: string
): string => {
const contents = listObjects.Contents;
const highestSuffix = contents?.reduce((maxNumber, item) => {
const matches = item.Key?.match(/-(\d+)\.\w+$/);

if (matches) {
const number = Number.parseInt(matches[1]);

return Math.max(maxNumber, number);
}

return maxNumber;
}, 0);

const nextNumber = highestSuffix ? highestSuffix + 1 : 1;

return `${baseFilename}-${nextNumber}.${fileExtension}`;
};

export {
getBaseName,
getFileExtension,
getPreferredBucket,
getFilenameWithSuffix,
};
75 changes: 72 additions & 3 deletions packages/s3/src/utils/s3Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import {
S3Client,
GetObjectCommand,
PutObjectCommand,
HeadObjectCommand,
PutObjectCommandOutput,
ListObjectsCommand,
ListObjectsCommandOutput,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

Expand Down Expand Up @@ -31,6 +35,14 @@ class s3Client {
this._bucket = bucket;
}

/**
* Generates a presigned URL for downloading a file from the specified S3 bucket.
*
* @param {string} filePath - The path or key of the file in the bucket.
* @param {string} originalFileName - The name to be used when downloading the file.
* @param {number} signedUrlExpiresInSecond - (Optional) The expiration time of the presigned URL in seconds (default: 3600 seconds).
* @returns {Promise<string | undefined>} A Promise that resolves with the generated presigned URL or undefined if an error occurs.
*/
public async generatePresignedUrl(
filePath: string,
originalFileName: string,
Expand All @@ -47,6 +59,12 @@ class s3Client {
});
}

/**
* Retrieves a file from the specified S3 bucket.
*
* @param {string} filePath - The path or key of the file to retrieve from the bucket.
* @returns {Promise<{ ContentType: string, Body: Buffer }>} A Promise that resolves with the retrieved file's content type and content as a Buffer.
*/
public async get(filePath: string) {
const command = new GetObjectCommand({
Bucket: this.bucket,
Expand All @@ -65,15 +83,66 @@ class s3Client {
};
}

public async upload(fileStream: Buffer, key: string, mimetype: string) {
const command = new PutObjectCommand({
/**
* Uploads a file to the specified S3 bucket.
*
* @param {Buffer} fileStream - The file content as a Buffer.
* @param {string} key - The key (file name) to use when storing the file in the bucket.
* @param {string} mimetype - The MIME type of the file.
* @returns {Promise<PutObjectCommandOutput>} A Promise that resolves with information about the uploaded object.
*/
public async upload(
fileStream: Buffer,
key: string,
mimetype: string
): Promise<PutObjectCommandOutput> {
const putCommand = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: fileStream,
ContentType: mimetype,
});

return await this._storageClient.send(command);
return await this._storageClient.send(putCommand);
}

/**
* Checks if a file with the given key exists in the S3 bucket.
* @param key - The key (combination of path & file name) to check for in the bucket.
* @returns Promise<boolean> - True if the file exists; otherwise, false.
*/
public async isFileExists(key: string): Promise<boolean> {
try {
const headObjectCommand = new HeadObjectCommand({
Bucket: this.bucket,
Key: key,
});

const response = await this._storageClient.send(headObjectCommand);

return !!response;
} catch (error: any) {
if (error.name === "NotFound") {
return false;
}

throw error;
}
}

/**
* Retrieves a list of objects from the S3 bucket with a specified prefix.
*
* @param {string} baseName - The prefix used to filter objects within the S3 bucket.
* @returns {Promise<ListObjectsCommandOutput>} A Promise that resolves to the result of the list operation.
*/
public async getObjects(baseName: string): Promise<ListObjectsCommandOutput> {
return await this._storageClient.send(
new ListObjectsCommand({
Bucket: this.bucket,
Prefix: baseName,
})
);
}

protected init(): S3Client {
Expand Down

0 comments on commit e6ad3e6

Please sign in to comment.