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:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 |
@@ -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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user