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
@@ -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
>