diff --git a/packages/core/types/src/file/provider.ts b/packages/core/types/src/file/provider.ts index 5cdb340d7a..f8a5748b47 100644 --- a/packages/core/types/src/file/provider.ts +++ b/packages/core/types/src/file/provider.ts @@ -1,3 +1,4 @@ +import { Readable } from "stream" import { FileAccessPermission } from "./common" /** @@ -144,4 +145,14 @@ export interface IFileProvider { getPresignedUploadUrl?( fileData: ProviderGetPresignedUploadUrlDTO ): Promise + + /** + * Get the file contents as a readable stream. + */ + getAsStream(fileData: ProviderGetFileDTO): Promise + + /** + * Get the file contents as a Node.js Buffer + */ + getAsBuffer(fileData: ProviderGetFileDTO): Promise } diff --git a/packages/core/types/src/file/service.ts b/packages/core/types/src/file/service.ts index 5accc578e2..662574c677 100644 --- a/packages/core/types/src/file/service.ts +++ b/packages/core/types/src/file/service.ts @@ -1,10 +1,17 @@ +import { Readable } from "stream" import { IModuleService } from "../modules-sdk" import { FileDTO, FilterableFileProps, UploadFileUrlDTO } from "./common" import { FindConfig } from "../common" import { Context } from "../shared-context" +import { IFileProvider } from "./provider" import { CreateFileDTO, GetUploadFileUrlDTO } from "./mutations" export interface IFileModuleService extends IModuleService { + /** + * Returns a reference to the file provider in use + */ + getProvider(): IFileProvider + /** * This method uploads files to the designated file storage system. * @@ -157,4 +164,22 @@ export interface IFileModuleService extends IModuleService { config?: FindConfig, sharedContext?: Context ): Promise<[FileDTO[], number]> + + /** + * Get the file contents as a readable stream. + * + * @example + * const stream = await fileModuleService.getAsStream("file_123") + * writeable.pipe(stream) + */ + getAsStream(id: string, sharedContext?: Context): Promise + + /** + * Get the file contents as a Node.js Buffer + * + * @example + * const contents = await fileModuleService.getAsBuffer("file_123") + * contents.toString('utf-8') + */ + getAsBuffer(id: string, sharedContext?: Context): Promise } diff --git a/packages/core/utils/src/file/abstract-file-provider.ts b/packages/core/utils/src/file/abstract-file-provider.ts index bcd3b12350..a803827c4a 100644 --- a/packages/core/utils/src/file/abstract-file-provider.ts +++ b/packages/core/utils/src/file/abstract-file-provider.ts @@ -1,3 +1,4 @@ +import type { Readable } from "stream" import { FileTypes, IFileProvider } from "@medusajs/types" /** @@ -49,9 +50,9 @@ import { FileTypes, IFileProvider } from "@medusajs/types" export class AbstractFileProviderService implements IFileProvider { /** * Each file provider has a unique ID used to identify it. The provider's ID - * will be stored as `fs_{identifier}_{id}`, where `{id}` is the provider's `id` + * will be stored as `fs_{identifier}_{id}`, where `{id}` is the provider's `id` * property in the `medusa-config.ts`. - * + * * @example * class MyFileProviderService extends AbstractFileProviderService { * static identifier = "my-file" @@ -63,11 +64,11 @@ export class AbstractFileProviderService implements IFileProvider { /** * This method validates the options of the provider set in `medusa-config.ts`. * Implementing this method is optional. It's useful if your provider requires custom validation. - * + * * If the options aren't valid, throw an error. - * + * * @param options - The provider's options. - * + * * @example * class MyFileProviderService extends AbstractFileProviderService { * static validateOptions(options: Record) { @@ -92,7 +93,7 @@ export class AbstractFileProviderService implements IFileProvider { /** * This method uploads a file using your provider's custom logic. In this method, you can upload the file * into your provider's storage, and return the uploaded file's details. - * + * * This method will be used when uploading product images, CSV files for imports, or other * custom file uploads. * @@ -174,4 +175,34 @@ export class AbstractFileProviderService implements IFileProvider { ): Promise { throw Error("getPresignedDownloadUrl must be overridden by the child class") } + + /** + * Get the file contents as a readable stream. + * + * @example + * class MyFileProviderService extends AbstractFileProviderService { + * // ... + * async getAsStream(file: ProviderDeleteFileDTO): Promise { + * this.client.getAsStream(file.fileKey) + * } + * } + */ + getAsStream(fileData: FileTypes.ProviderGetFileDTO): Promise { + throw Error("getAsStream must be overridden by the child class") + } + + /** + * Get the file contents as a Node.js Buffer + * + * @example + * class MyFileProviderService extends AbstractFileProviderService { + * // ... + * async getAsBuffer(file: ProviderDeleteFileDTO): Promise { + * this.client.getAsBuffer(file.fileKey) + * } + * } + */ + getAsBuffer(fileData: FileTypes.ProviderGetFileDTO): Promise { + throw Error("getAsBuffer must be overridden by the child class") + } } diff --git a/packages/modules/file/src/services/file-module-service.ts b/packages/modules/file/src/services/file-module-service.ts index c9b1473546..6273bd5db6 100644 --- a/packages/modules/file/src/services/file-module-service.ts +++ b/packages/modules/file/src/services/file-module-service.ts @@ -1,3 +1,4 @@ +import type { Readable } from "stream" import { Context, CreateFileDTO, @@ -28,6 +29,10 @@ export default class FileModuleService implements FileTypes.IFileModuleService { return joinerConfig } + getProvider() { + return this.fileProviderService_ + } + createFiles( data: CreateFileDTO[], sharedContext?: Context @@ -154,4 +159,26 @@ export default class FileModuleService implements FileTypes.IFileModuleService { 1, ] } + + /** + * Get the file contents as a readable stream. + * + * @example + * const stream = await fileModuleService.getAsStream("file_123") + * writeable.pipe(stream) + */ + getAsStream(id: string): Promise { + return this.fileProviderService_.getAsStream({ fileKey: id }) + } + + /** + * Get the file contents as a Node.js Buffer + * + * @example + * const contents = await fileModuleService.getAsBuffer("file_123") + * contents.toString('utf-8') + */ + getAsBuffer(id: string): Promise { + return this.fileProviderService_.getAsBuffer({ fileKey: id }) + } } diff --git a/packages/modules/file/src/services/file-provider-service.ts b/packages/modules/file/src/services/file-provider-service.ts index 6e9ab0f55d..e2ca3de60a 100644 --- a/packages/modules/file/src/services/file-provider-service.ts +++ b/packages/modules/file/src/services/file-provider-service.ts @@ -1,3 +1,4 @@ +import type { Readable } from "stream" import { Constructor, FileTypes } from "@medusajs/framework/types" import { MedusaError } from "@medusajs/framework/utils" import { FileProviderRegistrationPrefix } from "@types" @@ -68,4 +69,12 @@ export default class FileProviderService { return this.fileProvider_.getPresignedUploadUrl(fileData) } + + getAsStream(fileData: FileTypes.ProviderGetFileDTO): Promise { + return this.fileProvider_.getAsStream(fileData) + } + + getAsBuffer(fileData: FileTypes.ProviderGetFileDTO): Promise { + return this.fileProvider_.getAsBuffer(fileData) + } } diff --git a/packages/modules/providers/file-local/src/index.ts b/packages/modules/providers/file-local/src/index.ts index 2803b6c867..b4e3640834 100644 --- a/packages/modules/providers/file-local/src/index.ts +++ b/packages/modules/providers/file-local/src/index.ts @@ -1,5 +1,6 @@ import { ModuleProvider, Modules } from "@medusajs/framework/utils" import { LocalFileService } from "./services/local-file" +export { LocalFileService } const services = [LocalFileService] diff --git a/packages/modules/providers/file-local/src/services/local-file.ts b/packages/modules/providers/file-local/src/services/local-file.ts index b03fe5d7c5..642130fe14 100644 --- a/packages/modules/providers/file-local/src/services/local-file.ts +++ b/packages/modules/providers/file-local/src/services/local-file.ts @@ -3,8 +3,10 @@ import { AbstractFileProviderService, MedusaError, } from "@medusajs/framework/utils" +import { createReadStream } from "fs" import fs from "fs/promises" import path from "path" +import type { Readable } from "stream" export class LocalFileService extends AbstractFileProviderService { static identifier = "localfs" @@ -83,6 +85,24 @@ export class LocalFileService extends AbstractFileProviderService { return } + async getAsStream(file: FileTypes.ProviderGetFileDTO): Promise { + const baseDir = file.fileKey.startsWith("private-") + ? this.privateUploadDir_ + : this.uploadDir_ + + const filePath = this.getUploadFilePath(baseDir, file.fileKey) + return createReadStream(filePath) + } + + async getAsBuffer(file: FileTypes.ProviderGetFileDTO): Promise { + const baseDir = file.fileKey.startsWith("private-") + ? this.privateUploadDir_ + : this.uploadDir_ + + const filePath = this.getUploadFilePath(baseDir, file.fileKey) + return fs.readFile(filePath) + } + // The local file provider doesn't support presigned URLs for private files (i.e files not placed in /static). async getPresignedDownloadUrl( file: FileTypes.ProviderGetFileDTO diff --git a/packages/modules/providers/file-s3/src/services/s3-file.ts b/packages/modules/providers/file-s3/src/services/s3-file.ts index cb872af181..d6ca74a096 100644 --- a/packages/modules/providers/file-s3/src/services/s3-file.ts +++ b/packages/modules/providers/file-s3/src/services/s3-file.ts @@ -17,6 +17,7 @@ import { MedusaError, } from "@medusajs/framework/utils" import path from "path" +import { Readable } from "stream" import { ulid } from "ulid" type InjectedDependencies = { @@ -215,4 +216,42 @@ export class S3FileService extends AbstractFileProviderService { key: fileKey, } } + + async getAsStream(file: FileTypes.ProviderGetFileDTO): Promise { + if (!file?.filename) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `No filename provided` + ) + } + + const fileKey = `${this.config_.prefix}${file.filename}` + const response = await this.client_.send( + new GetObjectCommand({ + Key: fileKey, + Bucket: this.config_.bucket, + }) + ) + + return response.Body! as Readable + } + + async getAsBuffer(file: FileTypes.ProviderGetFileDTO): Promise { + if (!file?.filename) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `No filename provided` + ) + } + + const fileKey = `${this.config_.prefix}${file.filename}` + const response = await this.client_.send( + new GetObjectCommand({ + Key: fileKey, + Bucket: this.config_.bucket, + }) + ) + + return Buffer.from(await response.Body!.transformToByteArray()) + } }