feat: implement direct upload (#12328)

* feat: implement direct upload

* feat: add direct-upload endpoint

* refactor: implement feedback

* refactor: have a dedicated endpoint for direct uploads

* refactor: convert responses to snakecase

* refactor: rename method to createImport

* test: add tests for the presigned-urls endpoint
This commit is contained in:
Harminder Virk
2025-05-04 12:58:10 +02:00
committed by GitHub
parent ed55d17a36
commit 84f58f9058
22 changed files with 537 additions and 129 deletions
+177 -101
View File
@@ -17,16 +17,16 @@ export class Product {
/**
* This method creates a product import. The products are only imported after
* the import is confirmed using the {@link confirmImport} method.
*
* This method sends a request to the
*
* This method sends a request to the
* [Create Product Import](https://docs.medusajs.com/api/admin#products_postproductsimport)
* API route.
*
*
* @param body - The import's details.
* @param query - Query parameters to pass to the request.
* @param headers - Headers to pass in the request.
* @returns The import's details.
*
*
* @example
* sdk.admin.product.import({
* file // uploaded File instance
@@ -58,17 +58,93 @@ export class Product {
)
}
/**
* This method creates a product import. The products are only imported after
* the import is confirmed using the {@link confirmImport} method.
*
* This method sends a request to the
* [Create Product Import](https://docs.medusajs.com/api/admin#products_postproductsimport)
* API route.
*
* @param body - The import's details.
* @param query - Query parameters to pass to the request.
* @param headers - Headers to pass in the request.
* @returns The import's details.
*
* @example
* sdk.admin.product.createImport({
* file // uploaded File instance
* })
* .then(({ transaction_id }) => {
* console.log(transaction_id)
* })
*/
async createImport(
body: HttpTypes.AdminImportProductRequest,
query?: {},
headers?: ClientHeaders
) {
/**
* Get signed URL for file uploads
*/
const response =
await this.client.fetch<HttpTypes.AdminUploadPreSignedUrlResponse>(
"admin/uploads/presigned-urls",
{
method: "POST",
headers: headers,
body: {
originalname: body.file.name,
mime_type: body.file.type,
size: body.file.size,
} satisfies HttpTypes.AdminUploadPreSignedUrlRequest,
query,
}
)
/**
* Upload file using the signed URL. We cannot send cookies or any other
* special headers in this request, since external services like S3 will
* give a CORS error.
*/
await fetch(response.url, {
method: "PUT",
body: body.file,
})
/**
* Perform products import using the uploaded file name
*/
return await this.client.fetch<HttpTypes.AdminImportProductsResponse>(
"/admin/products/imports",
{
method: "POST",
headers: {
...headers,
},
body: {
file_key: response.filename,
originalname: response.originalname,
extension: response.extension,
size: response.size,
mime_type: response.mime_type,
} satisfies HttpTypes.AdminImportProductsRequest,
query,
}
)
}
/**
* This method confirms a product import created using the method {@link import}.
* It sends a request to the
* It sends a request to the
* [Confirm Product Import](https://docs.medusajs.com/api/admin#products_postproductsimporttransaction_idconfirm)
* API route.
*
*
* @param transactionId - The ID of the transaction of the created product import. This is returned
* by the API route used to create the product import.
* @param query - Query parameters to pass in the request.
* @param headers - Headers to pass in the request.
*
*
* @example
* sdk.admin.product.confirmImport("transaction_123")
* .then(() => {
@@ -93,23 +169,23 @@ export class Product {
/**
* This method starts a product export process to retrieve a CSV of exported products.
*
* You'll receive in the response the transaction ID of the workflow generating the CSV file.
* To check the status of the execution, send a `GET` request to
* `/admin/workflows-executions/export-products/:transaction-id`.
*
* Once the execution finishes successfully, a notification is created for the export.
* You can retrieve the notifications using the `/admin/notification` API route to
*
* You'll receive in the response the transaction ID of the workflow generating the CSV file.
* To check the status of the execution, send a `GET` request to
* `/admin/workflows-executions/export-products/:transaction-id`.
*
* Once the execution finishes successfully, a notification is created for the export.
* You can retrieve the notifications using the `/admin/notification` API route to
* retrieve the file's download URL.
*
*
* This method sends a request to the [Export Product](https://docs.medusajs.com/api/admin#products_postproductsexport)
* API route.
*
*
* @param body - The export's details.
* @param query - Filters to specify which products to export.
* @param headers - Headers to pass in the request.
* @param headers - Headers to pass in the request.
* @returns The export's details.
*
*
* @example
* sdk.admin.product.export({})
* .then(({ transaction_id }) => {
@@ -133,15 +209,15 @@ export class Product {
}
/**
* This method manages products to create, update, or delete them. It sends a request to the
* This method manages products to create, update, or delete them. It sends a request to the
* [Manage Products](https://docs.medusajs.com/api/admin#products_postproductsbatch)
* API route.
*
*
* @param body - The products to create, update, or delete.
* @param query - Configure the fields to retrieve in the products.
* @param headers - Headers to pass in the request
* @returns The batch operations details.
*
*
* @example
* sdk.admin.product.batch({
* create: [
@@ -189,15 +265,15 @@ export class Product {
}
/**
* This method creates a product. It sends a request to the
* This method creates a product. It sends a request to the
* [Create Product](https://docs.medusajs.com/api/admin#products_postproducts)
* API route.
*
*
* @param body - The product's details.
* @param query - Configure the fields to retrieve in the product.
* @param headers - Headers to pass in the request
* @returns The product's details.
*
*
* @example
* sdk.admin.product.create({
* title: "Shirt",
@@ -240,13 +316,13 @@ export class Product {
* This method updates a product. It sends a request to the
* [Update Product](https://docs.medusajs.com/api/admin#products_postproductsid)
* API route.
*
*
* @param id - The product's ID.
* @param body - The data to update in the product.
* @param query - Configure the fields to retrieve in the product.
* @param headers - Headers to pass in the request
* @returns The product's details.
*
*
* @example
* sdk.admin.product.update("prod_123", {
* title: "Shirt",
@@ -273,27 +349,27 @@ export class Product {
}
/**
* This method retrieves a paginated list of products. It sends a request to the
* This method retrieves a paginated list of products. It sends a request to the
* [List Products](https://docs.medusajs.com/api/admin#products_getproducts) API route.
*
*
* @param queryParams - Filters and pagination configurations.
* @param headers - Headers to pass in the request.
* @returns The paginated list of products.
*
*
* @example
* To retrieve the list of products:
*
*
* ```ts
* sdk.admin.product.list()
* .then(({ products, count, limit, offset }) => {
* console.log(products)
* })
* ```
*
*
* To configure the pagination, pass the `limit` and `offset` query parameters.
*
*
* For example, to retrieve only 10 items and skip 10 items:
*
*
* ```ts
* sdk.admin.product.list({
* limit: 10,
@@ -303,10 +379,10 @@ export class Product {
* console.log(products)
* })
* ```
*
*
* Using the `fields` query parameter, you can specify the fields and relations to retrieve
* in each products:
*
*
* ```ts
* sdk.admin.product.list({
* fields: "id,*variants"
@@ -315,7 +391,7 @@ export class Product {
* console.log(products)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async list(
@@ -332,27 +408,27 @@ export class Product {
}
/**
* This method retrieves a product by its ID. It sends a request to the
* This method retrieves a product by its ID. It sends a request to the
* [Get Product](https://docs.medusajs.com/api/admin#products_getproductsid)
* API route.
*
*
* @param id - The product's ID.
* @param query - Configure the fields to retrieve in the product.
* @param headers - Headers to pass in the request
* @returns The product's details.
*
*
* @example
* To retrieve a product by its ID:
*
*
* ```ts
* sdk.admin.product.retrieve("prod_123")
* .then(({ product }) => {
* console.log(product)
* })
* ```
*
*
* To specify the fields and relations to retrieve:
*
*
* ```ts
* sdk.admin.product.retrieve("prod_123", {
* fields: "id,*variants"
@@ -361,7 +437,7 @@ export class Product {
* console.log(product)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async retrieve(id: string, query?: SelectParams, headers?: ClientHeaders) {
@@ -378,11 +454,11 @@ export class Product {
* This method deletes a product. It sends a request to the
* [Delete Product](https://docs.medusajs.com/api/admin#products_deleteproductsid)
* API route.
*
*
* @param id - The product's ID.
* @param headers - Headers to pass in the request
* @returns The deletion's details.
*
*
* @example
* sdk.admin.product.delete("prod_123")
* .then(({ deleted }) => {
@@ -403,13 +479,13 @@ export class Product {
* This method manages the variants of a product. It sends a request to the
* [Manage Variants](https://docs.medusajs.com/api/admin#products_postproductsidvariantsbatch)
* API route.
*
*
* @param productId - The product's ID.
* @param body - The variants to create, update, or delete.
* @param query - Configure the fields to retrieve in the product variants.
* @param headers - Headers to pass in the request
* @returns The product variants' details.
*
*
* @example
* sdk.admin.product.batchVariants("prod_123", {
* create: [
@@ -454,13 +530,13 @@ export class Product {
* This method creates a variant for a product. It sends a request to the
* [Create Variant](https://docs.medusajs.com/api/admin#products_postproductsidvariants)
* API route.
*
*
* @param productId - The product's ID.
* @param body - The variant's details.
* @param query - Configure the fields to retrieve in the product.
* @param headers - Headers to pass in the request
* @returns The product's details.
*
*
* @example
* sdk.admin.product.createVariant("prod_123", {
* title: "Blue Shirt",
@@ -499,17 +575,17 @@ export class Product {
* This method updates a variant of a product. It sends a request to the
* [Update Variant](https://docs.medusajs.com/api/admin#products_postproductsidvariantsvariant_id)
* API route.
*
*
* @param productId - The product's ID.
* @param id - The variant's ID.
* @param body - The data to update in the variant.
* @param query - Configure the fields to retrieve in the product.
* @param headers - Headers to pass in the request
* @returns The product's details.
*
*
* @example
* sdk.admin.product.updateVariant(
* "prod_123",
* "prod_123",
* "variant_123",
* {
* title: "Blue Shirt",
@@ -538,28 +614,28 @@ export class Product {
}
/**
* This method retrieves a paginated list of products. It sends a request to the
* This method retrieves a paginated list of products. It sends a request to the
* [List Products](https://docs.medusajs.com/api/admin#products_getproductsidvariants) API route.
*
*
* @param productId - The ID of the product to retrieve its variants.
* @param query - Filters and pagination configurations.
* @param headers - Headers to pass in the request.
* @returns The paginated list of product variants.
*
*
* @example
* To retrieve the list of product variants:
*
*
* ```ts
* sdk.admin.product.listVariants("prod_123")
* .then(({ variants, count, limit, offset }) => {
* console.log(variants)
* })
* ```
*
*
* To configure the pagination, pass the `limit` and `offset` query parameters.
*
*
* For example, to retrieve only 10 items and skip 10 items:
*
*
* ```ts
* sdk.admin.product.listVariants("prod_123", {
* limit: 10,
@@ -569,10 +645,10 @@ export class Product {
* console.log(variants)
* })
* ```
*
*
* Using the `fields` query parameter, you can specify the fields and relations to retrieve
* in each product variant:
*
*
* ```ts
* sdk.admin.product.listVariants("prod_123", {
* fields: "id,*product"
@@ -581,7 +657,7 @@ export class Product {
* console.log(variants)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async listVariants(
@@ -602,16 +678,16 @@ export class Product {
* This method retrieves a product's variant. It sends a request to the
* [Retrieve Variant](https://docs.medusajs.com/api/admin#products_getproductsidvariantsvariant_id)
* API route.
*
*
* @param productId - The product's ID.
* @param id - The variant's ID.
* @param query - Configure the fields to retrieve in the product variant.
* @param headers - Headers to pass in the request
* @returns The product variant's details.
*
*
* @example
* To retrieve a product variant by its ID:
*
*
* ```ts
* sdk.admin.product.retrieveVariant(
* "prod_123",
@@ -621,9 +697,9 @@ export class Product {
* console.log(variant)
* })
* ```
*
*
* To specify the fields and relations to retrieve:
*
*
* ```ts
* sdk.admin.product.retrieveVariant(
* "prod_123",
@@ -636,7 +712,7 @@ export class Product {
* console.log(variant)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async retrieveVariant(
@@ -658,12 +734,12 @@ export class Product {
* This method deletes a product's variant. It sends a request to the
* [Delete Variant](https://docs.medusajs.com/api/admin#products_deleteproductsidvariantsvariant_id)
* API route.
*
*
* @param productId - The product's ID.
* @param id - The ID of the variant.
* @param headers - Headers to pass in the request
* @returns The deletion's details.
*
*
* @example
* sdk.admin.product.deleteVariant("prod_123", "variant_123")
* .then(({ deleted }) => {
@@ -681,22 +757,22 @@ export class Product {
}
/**
* This method manages a product's variant's inventories to associate them with inventory items,
* This method manages a product's variant's inventories to associate them with inventory items,
* update their inventory items, or delete their association with inventory items.
*
* It sends a request to the
*
* It sends a request to the
* [Manage Variant Inventory](https://docs.medusajs.com/api/admin#products_postproductsidvariantsinventoryitemsbatch)
* API route.
*
*
* @param productId - The ID of the product that the variant belongs to.
* @param body - The inventory items to create, update, or delete.
* @param query - Pass query parameters in the request.
* @param headers - Headers to pass in the request
* @returns The details of the created, updated, or deleted inventory items.
*
*
* @example
* sdk.admin.product.batchVariantInventoryItems(
* "prod_123",
* "prod_123",
* {
* create: [
* {
@@ -745,16 +821,16 @@ export class Product {
* This method creates an option in a product. It sends a request to the
* [Create Option](https://docs.medusajs.com/api/admin#products_postproductsidoptions)
* API route.
*
*
* @param productId - The product's ID.
* @param body - The option's details.
* @param query - Configure the fields to retrieve in the product.
* @param headers - Headers to pass in the request
* @returns The product's details.
*
*
* @example
* sdk.admin.product.createOption(
* "prod_123",
* "prod_123",
* {
* title: "Color",
* values: ["Green", "Blue"]
@@ -785,17 +861,17 @@ export class Product {
* This method updates a product's option. It sends a request to the
* [Update Option](https://docs.medusajs.com/api/admin#products_postproductsidoptionsoption_id)
* API route.
*
*
* @param productId - The product's ID.
* @param id - The ID of the option to update.
* @param body - The data to update in the option.
* @param query - Configure the fields to retrieve in the product.
* @param headers - Headers to pass in the request
* @returns The product's details.
*
*
* @example
* sdk.admin.product.updateOption(
* "prod_123",
* "prod_123",
* "prodopt_123",
* {
* title: "Color"
@@ -824,28 +900,28 @@ export class Product {
}
/**
* This method retrieves a paginated list of product options. It sends a request to the
* This method retrieves a paginated list of product options. It sends a request to the
* [List Options](https://docs.medusajs.com/api/admin#products_getproductsidoptions) API route.
*
*
* @param productId - The ID of the product to retrieve its options
* @param query - Filters and pagination configurations.
* @param headers - Headers to pass in the request.
* @returns The paginated list of product options.
*
*
* @example
* To retrieve the list of product options:
*
*
* ```ts
* sdk.admin.product.listOptions("prod_123")
* .then(({ product_options, count, limit, offset }) => {
* console.log(product_options)
* })
* ```
*
*
* To configure the pagination, pass the `limit` and `offset` query parameters.
*
*
* For example, to retrieve only 10 items and skip 10 items:
*
*
* ```ts
* sdk.admin.product.listOptions("prod_123", {
* limit: 10,
@@ -855,10 +931,10 @@ export class Product {
* console.log(product_options)
* })
* ```
*
*
* Using the `fields` query parameter, you can specify the fields and relations to retrieve
* in each product options:
*
*
* ```ts
* sdk.admin.product.listOptions("prod_123", {
* fields: "id,title"
@@ -867,7 +943,7 @@ export class Product {
* console.log(product_options)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async listOptions(
@@ -888,16 +964,16 @@ export class Product {
* This method retrieves a product's option. It sends a request to the
* [Get Option](https://docs.medusajs.com/api/admin#products_getproductsidoptionsoption_id)
* API route.
*
*
* @param productId - The product's ID.
* @param id - The product option's ID.
* @param query - Configure the fields to retrieve in the product option.
* @param headers - Headers to pass in the request
* @returns The product option's details.
*
*
* @example
* To retrieve a product option by its ID:
*
*
* ```ts
* sdk.admin.product.retrieveOption(
* "prod_123",
@@ -907,9 +983,9 @@ export class Product {
* console.log(product_option)
* })
* ```
*
*
* To specify the fields and relations to retrieve:
*
*
* ```ts
* sdk.admin.product.retrieveOption(
* "prod_123",
@@ -922,7 +998,7 @@ export class Product {
* console.log(product_option)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async retrieveOption(
@@ -941,15 +1017,15 @@ export class Product {
}
/**
* This method deletes a product's option. It sends a request to the
* This method deletes a product's option. It sends a request to the
* [Delete Option](https://docs.medusajs.com/api/admin#products_deleteproductsidoptionsoption_id)
* API route.
*
*
* @param productId - The product's ID.
* @param id - The option's ID.
* @param headers - Headers to pass in the request
* @returns The deletion's details.
*
*
* @example
* sdk.admin.product.deleteOption("prod_123", "prodopt_123")
* .then(({ deleted }) => {
+6 -1
View File
@@ -13,7 +13,12 @@ export type Config = {
auth?: {
type?: "jwt" | "session"
jwtTokenStorageKey?: string
jwtTokenStorageMethod?: "local" | "session" | "memory" | "custom" | "nostore"
jwtTokenStorageMethod?:
| "local"
| "session"
| "memory"
| "custom"
| "nostore"
fetchCredentials?: "include" | "omit" | "same-origin"
storage?: CustomStorage
}
+1 -1
View File
@@ -149,7 +149,7 @@ export interface IFileProvider {
/**
* Get the file contents as a readable stream.
*/
getAsStream(fileData: ProviderGetFileDTO): Promise<Readable>
getDownloadStream(fileData: ProviderGetFileDTO): Promise<Readable>
/**
* Get the file contents as a Node.js Buffer
+2 -2
View File
@@ -169,10 +169,10 @@ export interface IFileModuleService extends IModuleService {
* Get the file contents as a readable stream.
*
* @example
* const stream = await fileModuleService.getAsStream("file_123")
* const stream = await fileModuleService.getDownloadStream("file_123")
* writeable.pipe(stream)
*/
getAsStream(id: string, sharedContext?: Context): Promise<Readable>
getDownloadStream(id: string, sharedContext?: Context): Promise<Readable>
/**
* Get the file contents as a Node.js Buffer
@@ -1,3 +1,10 @@
import { BaseUploadFile } from "../common"
export type AdminUploadFile = BaseUploadFile
export interface AdminUploadPreSignedUrlRequest {
originalname: string
mime_type: string
size: number
access?: "public" | "private"
}
@@ -16,3 +16,36 @@ export interface AdminFileListResponse {
}
export type AdminFileDeleteResponse = DeleteResponse<"file">
export interface AdminUploadPreSignedUrlResponse {
/**
* The URL to be used for uploading the file
*/
url: string
/**
* The unique filename that can be used with the file provider
* to fetch the file
*/
filename: string
/**
* The original name of the file on the user's computer (aka clientName)
*/
originalname: string
/**
* The mime type of the file.
*/
mime_type: string
/**
* Extension of the file to be uploaded
*/
extension: string
/**
* Size of the file to be uploaded (in bytes)
*/
size: number
}
@@ -570,3 +570,11 @@ interface AdminDeleteProductVariantInventoryItem {
*/
variant_id: string
}
export interface AdminImportProductsRequest {
file_key: string
originalname: string
extension: string
size: number
mime_type: string
}
@@ -54,7 +54,7 @@ export interface AdminExportProductResponse {
export interface AdminImportProductResponse {
/**
* The ID of the import product workflow execution's transaction.
* The ID of the import product workflow execution's transaction.
* This is useful to confirm the import using the `/admin/products/:transaction-id/import` API route.
*/
transaction_id: string
@@ -73,6 +73,9 @@ export interface AdminImportProductResponse {
}
}
export interface AdminImportProductsResponse
extends AdminImportProductResponse {}
export interface AdminBatchProductVariantResponse
extends BatchResponse<AdminProductVariant> {}
@@ -187,8 +187,8 @@ export class AbstractFileProviderService implements IFileProvider {
* }
* }
*/
getAsStream(fileData: FileTypes.ProviderGetFileDTO): Promise<Readable> {
throw Error("getAsStream must be overridden by the child class")
getDownloadStream(fileData: FileTypes.ProviderGetFileDTO): Promise<Readable> {
throw Error("getDownloadStream must be overridden by the child class")
}
/**
@@ -0,0 +1,34 @@
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import {
importProductsWorkflowId,
waitConfirmationProductImportStepId,
} from "@medusajs/core-flows"
import { IWorkflowEngineService } from "@medusajs/framework/types"
import { Modules, TransactionHandlerType } from "@medusajs/framework/utils"
import { StepResponse } from "@medusajs/framework/workflows-sdk"
export const POST = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const workflowEngineService: IWorkflowEngineService = req.scope.resolve(
Modules.WORKFLOW_ENGINE
)
const transactionId = req.params.transaction_id
await workflowEngineService.setStepSuccess({
idempotencyKey: {
action: TransactionHandlerType.INVOKE,
transactionId,
stepId: waitConfirmationProductImportStepId,
workflowId: importProductsWorkflowId,
},
stepResponse: new StepResponse(true),
})
res.status(202).json({})
}
@@ -0,0 +1,27 @@
import {
MedusaResponse,
AuthenticatedMedusaRequest,
} from "@medusajs/framework/http"
import { Modules } from "@medusajs/framework/utils"
import type { HttpTypes } from "@medusajs/framework/types"
import { importProductsWorkflow } from "@medusajs/core-flows"
import type { AdminImportProductsType } from "../validators"
export const POST = async (
req: AuthenticatedMedusaRequest<AdminImportProductsType>,
res: MedusaResponse<HttpTypes.AdminImportProductResponse>
) => {
const fileProvider = req.scope.resolve(Modules.FILE)
const file = await fileProvider.getAsBuffer(req.validatedBody.file_key)
const { result, transaction } = await importProductsWorkflow(req.scope).run({
input: {
filename: req.validatedBody.originalname,
fileContent: file.toString("utf-8"),
},
})
res
.status(202)
.json({ transaction_id: transaction.transactionId, summary: result })
}
@@ -3,8 +3,8 @@ import {
validateAndTransformBody,
validateAndTransformQuery,
} from "@medusajs/framework"
import { maybeApplyLinkFilter, MiddlewareRoute } from "@medusajs/framework/http"
import multer from "multer"
import { maybeApplyLinkFilter, MiddlewareRoute } from "@medusajs/framework/http"
import { DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT } from "../../../utils/middlewares"
import { createBatchBody } from "../../utils/validators"
import * as QueryConfig from "./query-config"
@@ -25,6 +25,7 @@ import {
AdminGetProductsParams,
AdminGetProductVariantParams,
AdminGetProductVariantsParams,
AdminImportProducts,
AdminUpdateProduct,
AdminUpdateProductOption,
AdminUpdateProductVariant,
@@ -34,9 +35,6 @@ import {
} from "./validators"
import IndexEngineFeatureFlag from "../../../loaders/feature-flags/index-engine"
// TODO: For now we keep the files in memory, as that's how they get passed to the workflows
// This will need revisiting once we are closer to prod-ready v2, since with workflows and potentially
// services on other machines using streams is not as simple as it used to be.
const upload = multer({ storage: multer.memoryStorage() })
export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
@@ -104,6 +102,11 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
matcher: "/admin/products/import",
middlewares: [upload.single("file")],
},
{
method: ["POST"],
matcher: "/admin/products/imports",
middlewares: [validateAndTransformBody(AdminImportProducts)],
},
{
method: ["POST"],
matcher: "/admin/products/import/:transaction_id/confirm",
@@ -1,6 +1,6 @@
import { BatchMethodRequest } from "@medusajs/framework/types"
import { BatchMethodRequest, HttpTypes } from "@medusajs/framework/types"
import { ProductStatus } from "@medusajs/framework/utils"
import { z } from "zod"
import { z, ZodType } from "zod"
import {
applyAndAndOrOperators,
booleanString,
@@ -341,3 +341,12 @@ export type AdminBatchVariantInventoryItemsType = BatchMethodRequest<
AdminBatchUpdateVariantInventoryItemType,
AdminBatchDeleteVariantInventoryItemType
>
export const AdminImportProducts = z.object({
file_key: z.string(),
originalname: z.string(),
extension: z.string(),
size: z.number(),
mime_type: z.string(),
}) satisfies ZodType<HttpTypes.AdminImportProductsRequest>
export type AdminImportProductsType = z.infer<typeof AdminImportProducts>
@@ -1,8 +1,11 @@
import multer from "multer"
import { MiddlewareRoute } from "@medusajs/framework/http"
import {
MiddlewareRoute,
validateAndTransformBody,
} from "@medusajs/framework/http"
import { validateAndTransformQuery } from "@medusajs/framework"
import { retrieveUploadConfig } from "./query-config"
import { AdminGetUploadParams } from "./validators"
import { AdminGetUploadParams, AdminUploadPreSignedUrl } from "./validators"
// TODO: For now we keep the files in memory, as that's how they get passed to the workflows
// This will need revisiting once we are closer to prod-ready v2, since with workflows and potentially
@@ -31,4 +34,9 @@ export const adminUploadRoutesMiddlewares: MiddlewareRoute[] = [
matcher: "/admin/uploads/:id",
middlewares: [],
},
{
method: ["POST"],
matcher: "/admin/uploads/presigned-urls",
middlewares: [validateAndTransformBody(AdminUploadPreSignedUrl)],
},
]
@@ -0,0 +1,49 @@
import { ulid } from "ulid"
import { MIMEType } from "util"
import type {
MedusaResponse,
AuthenticatedMedusaRequest,
} from "@medusajs/framework/http"
import {
Modules,
MedusaError,
MedusaErrorTypes,
} from "@medusajs/framework/utils"
import type { HttpTypes } from "@medusajs/framework/types"
import type { AdminUploadPreSignedUrlType } from "../validators"
export const POST = async (
req: AuthenticatedMedusaRequest<AdminUploadPreSignedUrlType>,
res: MedusaResponse<HttpTypes.AdminUploadPreSignedUrlResponse>
) => {
const fileProvider = req.scope.resolve(Modules.FILE)
let type: MIMEType
try {
type = new MIMEType(req.validatedBody.mime_type)
} catch {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
`Invalid file type "${req.validatedBody.mime_type}"`,
MedusaErrorTypes.INVALID_DATA
)
}
const extension = type.subtype
const uniqueFilename = `${ulid()}.${extension}`
const response = await fileProvider.getUploadFileUrls({
filename: uniqueFilename,
mimeType: req.validatedBody.mime_type,
access: req.validatedBody.access ?? "private",
})
res.json({
url: response.url,
filename: response.key,
mime_type: type.toString(),
size: req.validatedBody.size,
extension,
originalname: req.validatedBody.originalname,
})
}
@@ -1,5 +1,17 @@
import { z, ZodType } from "zod"
import { HttpTypes } from "@medusajs/types"
import { createSelectParams } from "../../utils/validators"
import { z } from "zod"
export type AdminGetUploadParamsType = z.infer<typeof AdminGetUploadParams>
export const AdminGetUploadParams = createSelectParams()
export const AdminUploadPreSignedUrl = z.object({
originalname: z.string(),
mime_type: z.string(),
size: z.number(),
access: z.enum(["public", "private"]).optional(),
}) satisfies ZodType<HttpTypes.AdminUploadPreSignedUrlRequest>
export type AdminUploadPreSignedUrlType = z.infer<
typeof AdminUploadPreSignedUrl
>
@@ -167,8 +167,8 @@ export default class FileModuleService implements FileTypes.IFileModuleService {
* const stream = await fileModuleService.getAsStream("file_123")
* writeable.pipe(stream)
*/
getAsStream(id: string): Promise<Readable> {
return this.fileProviderService_.getAsStream({ fileKey: id })
getDownloadStream(id: string): Promise<Readable> {
return this.fileProviderService_.getDownloadStream({ fileKey: id })
}
/**
@@ -70,8 +70,8 @@ export default class FileProviderService {
return this.fileProvider_.getPresignedUploadUrl(fileData)
}
getAsStream(fileData: FileTypes.ProviderGetFileDTO): Promise<Readable> {
return this.fileProvider_.getAsStream(fileData)
getDownloadStream(fileData: FileTypes.ProviderGetFileDTO): Promise<Readable> {
return this.fileProvider_.getDownloadStream(fileData)
}
getAsBuffer(fileData: FileTypes.ProviderGetFileDTO): Promise<Buffer> {
@@ -85,7 +85,9 @@ export class LocalFileService extends AbstractFileProviderService {
return
}
async getAsStream(file: FileTypes.ProviderGetFileDTO): Promise<Readable> {
async getDownloadStream(
file: FileTypes.ProviderGetFileDTO
): Promise<Readable> {
const baseDir = file.fileKey.startsWith("private-")
? this.privateUploadDir_
: this.uploadDir_
@@ -124,6 +126,47 @@ export class LocalFileService extends AbstractFileProviderService {
return this.getUploadFileUrl(file.fileKey)
}
/**
* Returns the pre-signed URL that the client (frontend) can use to trigger
* a file upload. In this case, the Medusa backend will implement the
* "/upload" endpoint to perform the file upload.
*
* Since, we do not want the client to perform link detection on the frontend
* and then prepare a different kind of request for cloud providers and different
* request for the local server, we will have to make these URLs self sufficient.
*
* What is a self sufficient URL
*
* - There should be no need to specify the MIME type or filename separately in request body (cloud providers don't allow it).
* - There should be no need to pass auth headers like cookies. Again cloud providers
* won't allow it and will likely result in a CORS error.
*/
async getPresignedUploadUrl(
fileData: FileTypes.ProviderGetPresignedUploadUrlDTO
): Promise<FileTypes.ProviderFileResultDTO> {
if (!fileData?.filename) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`No filename provided`
)
}
const uploadUrl = new URL(
"upload",
`${this.backendUrl_.replace(/\/$/, "")}/`
)
uploadUrl.searchParams.set("filename", fileData.filename)
if (fileData.mimeType) {
uploadUrl.searchParams.set("type", fileData.mimeType)
}
return {
url: uploadUrl.toString(),
key: fileData.filename,
}
}
private getUploadFilePath = (baseDir: string, fileKey: string) => {
return path.join(baseDir, fileKey)
}
@@ -217,15 +217,17 @@ export class S3FileService extends AbstractFileProviderService {
}
}
async getAsStream(file: FileTypes.ProviderGetFileDTO): Promise<Readable> {
if (!file?.filename) {
async getDownloadStream(
file: FileTypes.ProviderGetFileDTO
): Promise<Readable> {
if (!file?.fileKey) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`No filename provided`
`No fileKey provided`
)
}
const fileKey = `${this.config_.prefix}${file.filename}`
const fileKey = `${this.config_.prefix}${file.fileKey}`
const response = await this.client_.send(
new GetObjectCommand({
Key: fileKey,
@@ -237,14 +239,14 @@ export class S3FileService extends AbstractFileProviderService {
}
async getAsBuffer(file: FileTypes.ProviderGetFileDTO): Promise<Buffer> {
if (!file?.filename) {
if (!file?.fileKey) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`No filename provided`
`No fileKey provided`
)
}
const fileKey = `${this.config_.prefix}${file.filename}`
const fileKey = `${this.config_.prefix}${file.fileKey}`
const response = await this.client_.send(
new GetObjectCommand({
Key: fileKey,