feat: Add support for uploading a file directly to the file provider from the client (#12224)

* feat: Add support for uploading a file directly to the file provider from the client

* fix: Add missing types and add a couple of module tests

* fix: Allow nested routes, add test for it
This commit is contained in:
Stevche Radevski
2025-04-18 10:22:00 +02:00
committed by GitHub
parent 6b1d8cd3d4
commit c4a0b63778
11 changed files with 314 additions and 5 deletions

View File

@@ -1,3 +1,8 @@
/**
* The access level of the file.
*/
export type FileAccessPermission = "public" | "private"
/** /**
* The File details. * The File details.
*/ */
@@ -24,3 +29,14 @@ export interface FilterableFileProps {
*/ */
id?: string id?: string
} }
export interface UploadFileUrlDTO {
/**
* The URL of the file.
*/
url: string
/**
* The key of the file.
*/
key: string
}

View File

@@ -1,3 +1,5 @@
import { FileAccessPermission } from "./common"
/** /**
* The File to be created. * The File to be created.
*/ */
@@ -9,7 +11,7 @@ export interface CreateFileDTO {
/** /**
* The mimetype of the uploaded file * The mimetype of the uploaded file
* *
* @example * @example
* image/png * image/png
*/ */
@@ -23,5 +25,22 @@ export interface CreateFileDTO {
/** /**
* The access level of the file. Defaults to private if not passed * The access level of the file. Defaults to private if not passed
*/ */
access?: "public" | "private" access?: FileAccessPermission
}
export interface GetUploadFileUrlDTO {
/**
* The name of the file to be uploaded
*/
filename: string
/**
* The mimetype of the file to be uploaded
*/
mimeType?: string
/**
* The access level of the file to be uploaded. Defaults to private if not passed
*/
access?: FileAccessPermission
} }

View File

@@ -1,3 +1,5 @@
import { FileAccessPermission } from "./common"
/** /**
* @interface * @interface
* *
@@ -71,7 +73,34 @@ export type ProviderUploadFileDTO = {
/** /**
* The access level of the file. Defaults to private if not passed * The access level of the file. Defaults to private if not passed
*/ */
access?: "public" | "private" access?: FileAccessPermission
}
/**
* @interface
*
* The details of the file to get a presigned upload URL for.
*/
export type ProviderGetPresignedUploadUrlDTO = {
/**
* The filename of the file to get a presigned upload URL for.
*/
filename: string
/**
* The mimetype of the file to get a presigned upload URL for.
*/
mimeType?: string
/**
* The access level of the file to get a presigned upload URL for.
*/
access?: FileAccessPermission
/**
* The validity of the presigned upload URL in seconds.
*/
expiresIn?: number
} }
export interface IFileProvider { export interface IFileProvider {
@@ -103,4 +132,16 @@ export interface IFileProvider {
* *
*/ */
getPresignedDownloadUrl(fileData: ProviderGetFileDTO): Promise<string> getPresignedDownloadUrl(fileData: ProviderGetFileDTO): Promise<string>
/**
* This method is used to get a presigned upload URL for a file.
* If the file provider does not support direct upload, an exception will be thrown when calling this method.
*
* @param {ProviderGetPresignedUploadUrlDTO} fileData - The details of the file to get a presigned upload URL for.
* @returns {Promise<ProviderFileResultDTO>} The presigned URL and file key to upload the file to
*
*/
getPresignedUploadUrl?(
fileData: ProviderGetPresignedUploadUrlDTO
): Promise<ProviderFileResultDTO>
} }

View File

@@ -1,8 +1,8 @@
import { IModuleService } from "../modules-sdk" import { IModuleService } from "../modules-sdk"
import { FileDTO, FilterableFileProps } from "./common" import { FileDTO, FilterableFileProps, UploadFileUrlDTO } from "./common"
import { FindConfig } from "../common" import { FindConfig } from "../common"
import { Context } from "../shared-context" import { Context } from "../shared-context"
import { CreateFileDTO } from "./mutations" import { CreateFileDTO, GetUploadFileUrlDTO } from "./mutations"
export interface IFileModuleService extends IModuleService { export interface IFileModuleService extends IModuleService {
/** /**
@@ -41,6 +41,43 @@ export interface IFileModuleService extends IModuleService {
createFiles(data: CreateFileDTO, sharedContext?: Context): Promise<FileDTO> createFiles(data: CreateFileDTO, sharedContext?: Context): Promise<FileDTO>
/**
* This method gets the upload URL for a file.
*
* @param {GetUploadFileUrlDTO} data - The file information to get the upload URL for.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<UploadFileUrlDTO>} The upload URL for the file.
*
* @example
* const uploadInfo = await fileModuleService.getUploadFileUrls({
* filename: "product.png",
* mimeType: "image/png",
* })
*/
getUploadFileUrls(
data: GetUploadFileUrlDTO,
sharedContext?: Context
): Promise<UploadFileUrlDTO>
/**
* This method uploads files to the designated file storage system.
*
* @param {GetUploadFileUrlDTO[]} data - The file information to get the upload URL for.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<UploadFileUrlDTO[]>} The upload URLs for the files.
*
* @example
* const [uploadInfo] = await fileModuleService.getUploadFileUrls([{
* filename: "product.png",
* mimeType: "image/png",
* }])
*/
getUploadFileUrls(
data: GetUploadFileUrlDTO[],
sharedContext?: Context
): Promise<UploadFileUrlDTO[]>
/** /**
* This method deletes files by their IDs. * This method deletes files by their IDs.
* *

View File

@@ -27,6 +27,15 @@ export class FileProviderServiceFixtures extends AbstractFileProviderService {
return "" return ""
} }
async getPresignedUploadUrl(
fileData: FileTypes.ProviderGetPresignedUploadUrlDTO
): Promise<FileTypes.ProviderFileResultDTO> {
return {
url: "presigned-url/" + fileData.filename,
key: fileData.filename,
}
}
} }
export const services = [FileProviderServiceFixtures] export const services = [FileProviderServiceFixtures]

View File

@@ -63,6 +63,31 @@ moduleIntegrationTestRunner<IFileModuleService>({
const downloadUrl = await service.retrieveFile("test.jpg") const downloadUrl = await service.retrieveFile("test.jpg")
expect(await new Response(downloadUrl.url).text()).toEqual("test") expect(await new Response(downloadUrl.url).text()).toEqual("test")
}) })
it("generates a presigned upload URL", async () => {
const res = await service.getUploadFileUrls({
filename: "test.jpg",
mimeType: "image/jpeg",
})
expect(res).toEqual({
url: "presigned-url/test.jpg",
key: "test.jpg",
})
})
it("fails to get a presigned upload URL if a filename isn't provided", async () => {
const err = await service
.getUploadFileUrls({
filename: "",
mimeType: "image/jpeg",
})
.catch((err) => err)
expect(err.message).toEqual(
"File name is required to get a presigned upload URL"
)
})
}) })
}, },
}) })

View File

@@ -1,7 +1,9 @@
import { import {
Context, Context,
CreateFileDTO, CreateFileDTO,
GetUploadFileUrlDTO,
FileDTO, FileDTO,
UploadFileUrlDTO,
FileTypes, FileTypes,
FilterableFileProps, FilterableFileProps,
FindConfig, FindConfig,
@@ -49,6 +51,27 @@ export default class FileModuleService implements FileTypes.IFileModuleService {
return Array.isArray(data) ? result : result[0] return Array.isArray(data) ? result : result[0]
} }
getUploadFileUrls(
data: GetUploadFileUrlDTO[],
sharedContext?: Context
): Promise<UploadFileUrlDTO[]>
getUploadFileUrls(
data: GetUploadFileUrlDTO,
sharedContext?: Context
): Promise<UploadFileUrlDTO>
async getUploadFileUrls(
data: GetUploadFileUrlDTO[] | GetUploadFileUrlDTO
): Promise<UploadFileUrlDTO[] | UploadFileUrlDTO> {
const input = Array.isArray(data) ? data : [data]
const result = await Promise.all(
input.map((file) => this.fileProviderService_.getPresignedUploadUrl(file))
)
return Array.isArray(data) ? result : result[0]
}
async deleteFiles(ids: string[], sharedContext?: Context): Promise<void> async deleteFiles(ids: string[], sharedContext?: Context): Promise<void>
async deleteFiles(id: string, sharedContext?: Context): Promise<void> async deleteFiles(id: string, sharedContext?: Context): Promise<void>
async deleteFiles(ids: string[] | string): Promise<void> { async deleteFiles(ids: string[] | string): Promise<void> {

View File

@@ -48,4 +48,24 @@ export default class FileProviderService {
): Promise<string> { ): Promise<string> {
return this.fileProvider_.getPresignedDownloadUrl(fileData) return this.fileProvider_.getPresignedDownloadUrl(fileData)
} }
getPresignedUploadUrl(
fileData: FileTypes.ProviderGetPresignedUploadUrlDTO
): Promise<FileTypes.ProviderFileResultDTO> {
if (!this.fileProvider_.getPresignedUploadUrl) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Provider does not support presigned upload URLs"
)
}
if (!fileData.filename) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"File name is required to get a presigned upload URL"
)
}
return this.fileProvider_.getPresignedUploadUrl(fileData)
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -81,4 +81,83 @@ describe.skip("S3 File Plugin", () => {
expect(response.status).toEqual(404) expect(response.status).toEqual(404)
}) })
}) })
it("gets a presigned upload URL and uploads a file successfully", async () => {
const fileContent = await fs.readFile(fixtureImagePath)
const fixtureAsBinary = fileContent.toString("binary")
const resp = await s3Service.getPresignedUploadUrl({
filename: "catphoto.jpg",
mimeType: "image/jpeg",
access: "private",
})
expect(resp).toEqual({
key: expect.stringMatching(/tests\/catphoto.*\.jpg/),
url: expect.stringMatching(/https:\/\/.*catphoto\.jpg/),
})
const uploadResp = await axios.put(resp.url, fileContent, {
headers: {
// On Digitalocean, among others, despite the ACL set on the upload URL, the caller can set the acl to anything they want.
// On AWS passing the ACL in the upload will fail since it's set on the signed URL.
// "x-amz-acl": "private",
"Content-Type": "image/jpeg",
},
})
expect(uploadResp.status).toEqual(200)
const signedUrl = await s3Service.getPresignedDownloadUrl({
fileKey: resp.key,
})
const signedUrlFile = Buffer.from(
await axios
.get(signedUrl, { responseType: "arraybuffer" })
.then((r) => r.data)
)
expect(signedUrlFile.toString("binary")).toEqual(fixtureAsBinary)
await s3Service.delete({ fileKey: resp.key })
})
it("gets a presigned upload URL for a nested filename structure and uploads a file successfully", async () => {
const fileContent = await fs.readFile(fixtureImagePath)
const fixtureAsBinary = fileContent.toString("binary")
const resp = await s3Service.getPresignedUploadUrl({
filename: "testfolder/catphoto.jpg",
mimeType: "image/jpeg",
access: "private",
})
expect(resp).toEqual({
key: expect.stringMatching(/tests\/testfolder\/catphoto.*\.jpg/),
url: expect.stringMatching(/https:\/\/.*testfolder\/catphoto\.jpg/),
})
const uploadResp = await axios.put(resp.url, fileContent, {
headers: {
"Content-Type": "image/jpeg",
},
})
expect(uploadResp.status).toEqual(200)
const signedUrl = await s3Service.getPresignedDownloadUrl({
fileKey: resp.key,
})
const signedUrlFile = Buffer.from(
await axios
.get(signedUrl, { responseType: "arraybuffer" })
.then((r) => r.data)
)
expect(signedUrlFile.toString("binary")).toEqual(fixtureAsBinary)
await s3Service.delete({ fileKey: resp.key })
})
}) })

View File

@@ -1,6 +1,7 @@
import { import {
DeleteObjectCommand, DeleteObjectCommand,
GetObjectCommand, GetObjectCommand,
ObjectCannedACL,
PutObjectCommand, PutObjectCommand,
S3Client, S3Client,
S3ClientConfigType, S3ClientConfigType,
@@ -36,6 +37,8 @@ interface S3FileServiceConfig {
additionalClientConfig?: Record<string, any> additionalClientConfig?: Record<string, any>
} }
const DEFAULT_UPLOAD_EXPIRATION_DURATION_SECONDS = 60 * 60
export class S3FileService extends AbstractFileProviderService { export class S3FileService extends AbstractFileProviderService {
static identifier = "s3" static identifier = "s3"
protected config_: S3FileServiceConfig protected config_: S3FileServiceConfig
@@ -175,4 +178,41 @@ export class S3FileService extends AbstractFileProviderService {
expiresIn: this.config_.downloadFileDuration, expiresIn: this.config_.downloadFileDuration,
}) })
} }
// Note: Some providers (eg. AWS S3) allows IAM policies to further restrict what can be uploaded.
async getPresignedUploadUrl(
fileData: FileTypes.ProviderGetPresignedUploadUrlDTO
): Promise<FileTypes.ProviderFileResultDTO> {
if (!fileData?.filename) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`No filename provided`
)
}
const fileKey = `${this.config_.prefix}${fileData.filename}`
let acl: ObjectCannedACL | undefined
if (fileData.access) {
acl = fileData.access === "public" ? "public-read" : "private"
}
// Using content-type, acl, etc. doesn't work with all providers, and some simply ignore it.
const command = new PutObjectCommand({
Bucket: this.config_.bucket,
ContentType: fileData.mimeType,
ACL: acl,
Key: fileKey,
})
const signedUrl = await getSignedUrl(this.client_, command, {
expiresIn:
fileData.expiresIn ?? DEFAULT_UPLOAD_EXPIRATION_DURATION_SECONDS,
})
return {
url: signedUrl,
key: fileKey,
}
}
} }