feat: Add local file provider and wire everything up in the file module (#7134)
This commit is contained in:
1 parent
fe68b5c0f2
commit
614d659a59
35 files changed
+682
-47
No files matched your search
@@ -0,0 +1,125 @@
|
|||||||
|
import { medusaIntegrationTestRunner } from "medusa-test-utils"
|
||||||
|
import FormData from "form-data"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import {
|
||||||
|
adminHeaders,
|
||||||
|
createAdminUser,
|
||||||
|
} from "../../../helpers/create-admin-user"
|
||||||
|
|
||||||
|
jest.setTimeout(30000)
|
||||||
|
|
||||||
|
const getUploadReq = (files: { name: string; content: string }[]) => {
|
||||||
|
const form = new FormData()
|
||||||
|
files.forEach((file) => {
|
||||||
|
form.append("files", Buffer.from(file.content), file.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
...adminHeaders.headers,
|
||||||
|
...form.getHeaders(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
medusaIntegrationTestRunner({
|
||||||
|
env: {
|
||||||
|
MEDUSA_FF_MEDUSA_V2: true,
|
||||||
|
},
|
||||||
|
testSuite: ({ dbConnection, getContainer, api }) => {
|
||||||
|
let appContainer
|
||||||
|
beforeAll(() => {})
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(path.join(process.cwd(), "uploads"), { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
appContainer = getContainer()
|
||||||
|
await createAdminUser(dbConnection, adminHeaders, appContainer)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("POST /admin/uploads with", () => {
|
||||||
|
beforeEach(async () => {})
|
||||||
|
|
||||||
|
it("uploads a single file successfully", async () => {
|
||||||
|
const { form, meta } = getUploadReq([
|
||||||
|
{ name: "first.jpeg", content: "first content" },
|
||||||
|
{ name: "second.jpeg", content: "second content" },
|
||||||
|
])
|
||||||
|
const response = await api.post("/admin/uploads", form, meta)
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200)
|
||||||
|
expect(response.data.files).toHaveLength(2)
|
||||||
|
expect(response.data.files).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: expect.any(String),
|
||||||
|
url: expect.any(String),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("GET /admin/uploads/:id", () => {
|
||||||
|
let fileKey = ""
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { form, meta } = getUploadReq([
|
||||||
|
{ name: "test.jpeg", content: "test content" },
|
||||||
|
])
|
||||||
|
|
||||||
|
fileKey = (await api.post("/admin/uploads", form, meta)).data.files[0]
|
||||||
|
.id
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gets a URL to the requested file successfully", async () => {
|
||||||
|
const response = await api.get(
|
||||||
|
`/admin/uploads/${fileKey}`,
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200)
|
||||||
|
expect(response.data.file.url).toEqual(
|
||||||
|
expect.stringContaining(`/uploads/${fileKey}`)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("DELETE /admin/uploads/:id", () => {
|
||||||
|
let fileKey = ""
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { form, meta } = getUploadReq([
|
||||||
|
{ name: "test.jpeg", content: "test content" },
|
||||||
|
])
|
||||||
|
|
||||||
|
fileKey = (await api.post("/admin/uploads", form, meta)).data.files[0]
|
||||||
|
.id
|
||||||
|
})
|
||||||
|
|
||||||
|
it("deletes the specified file successfully", async () => {
|
||||||
|
const response = await api.delete(
|
||||||
|
`/admin/uploads/${fileKey}`,
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200)
|
||||||
|
expect(response.data).toEqual({
|
||||||
|
id: fileKey,
|
||||||
|
object: "file",
|
||||||
|
deleted: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { response: err } = await api
|
||||||
|
.get(`/admin/uploads/${fileKey}`, adminHeaders)
|
||||||
|
.catch((e) => e)
|
||||||
|
|
||||||
|
expect(err.status).toEqual(404)
|
||||||
|
expect(err.data.message).toEqual(`File with key ${fileKey} not found`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -75,6 +75,21 @@ module.exports = {
|
|||||||
resolve: "@medusajs/inventory-next",
|
resolve: "@medusajs/inventory-next",
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
|
[Modules.FILE]: {
|
||||||
|
resolve: "@medusajs/file",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/file-local-next",
|
||||||
|
options: {
|
||||||
|
config: {
|
||||||
|
local: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
[Modules.PRODUCT]: true,
|
[Modules.PRODUCT]: true,
|
||||||
[Modules.PRICING]: true,
|
[Modules.PRICING]: true,
|
||||||
[Modules.PROMOTION]: true,
|
[Modules.PROMOTION]: true,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"@swc/core": "^1.4.8",
|
"@swc/core": "^1.4.8",
|
||||||
"@swc/jest": "^0.2.36",
|
"@swc/jest": "^0.2.36",
|
||||||
"babel-preset-medusa-package": "*",
|
"babel-preset-medusa-package": "*",
|
||||||
|
"form-data": "^4.0.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"jest-environment-node": "26.6.2"
|
"jest-environment-node": "26.6.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type UploadFilesStepInput = {
|
|||||||
files: {
|
files: {
|
||||||
filename: string
|
filename: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
content: Blob
|
content: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type WorkflowInput = {
|
|||||||
files: {
|
files: {
|
||||||
filename: string
|
filename: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
content: Blob
|
content: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.DS_store
|
||||||
|
yarn.lock
|
||||||
Whitespace-only changes.
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
globals: {
|
||||||
|
"ts-jest": {
|
||||||
|
tsconfig: "tsconfig.spec.json",
|
||||||
|
isolatedModules: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
"^.+\\.[jt]s?$": "ts-jest",
|
||||||
|
},
|
||||||
|
testEnvironment: `node`,
|
||||||
|
moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`],
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@medusajs/file-local-next",
|
||||||
|
"version": "0.0.2",
|
||||||
|
"description": "Local filesystem file storage for Medusa",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/medusajs/medusa",
|
||||||
|
"directory": "packages/file-local"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"author": "Medusa",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"prepublishOnly": "cross-env NODE_ENV=production tsc --build",
|
||||||
|
"test": "jest --passWithNoTests src",
|
||||||
|
"build": "rimraf dist && tsc -p ./tsconfig.json",
|
||||||
|
"watch": "tsc --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"cross-env": "^5.2.1",
|
||||||
|
"jest": "^25.5.4",
|
||||||
|
"rimraf": "^5.0.1",
|
||||||
|
"typescript": "^4.9.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@medusajs/utils": "^1.11.7"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"medusa-plugin",
|
||||||
|
"medusa-plugin-file"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ModuleProviderExports } from "@medusajs/types"
|
||||||
|
import { LocalFileService } from "./services/local-file"
|
||||||
|
|
||||||
|
const services = [LocalFileService]
|
||||||
|
|
||||||
|
const providerExport: ModuleProviderExports = {
|
||||||
|
services,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default providerExport
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { FileTypes } from "@medusajs/types"
|
||||||
|
import { AbstractFileProviderService, MedusaError } from "@medusajs/utils"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export class LocalFileService extends AbstractFileProviderService {
|
||||||
|
static identifier = "localfs"
|
||||||
|
protected uploadDir_: string
|
||||||
|
protected backendUrl_: string
|
||||||
|
|
||||||
|
constructor(_, options) {
|
||||||
|
super()
|
||||||
|
this.uploadDir_ = options?.upload_dir || "uploads"
|
||||||
|
this.backendUrl_ = options?.backend_url || "http://localhost:9000"
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(
|
||||||
|
file: FileTypes.ProviderUploadFileDTO
|
||||||
|
): Promise<FileTypes.ProviderFileResultDTO> {
|
||||||
|
if (!file) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, `No file provided`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.filename) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`No filename provided`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedFilename = path.parse(file.filename)
|
||||||
|
|
||||||
|
if (parsedFilename.dir) {
|
||||||
|
this.ensureDirExists(parsedFilename.dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileKey = path.join(
|
||||||
|
parsedFilename.dir,
|
||||||
|
`${Date.now()}-${parsedFilename.base}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const filePath = this.getUploadFilePath(fileKey)
|
||||||
|
const fileUrl = this.getUploadFileUrl(fileKey)
|
||||||
|
|
||||||
|
const content = Buffer.from(file.content, "binary")
|
||||||
|
await fs.writeFile(filePath, content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: fileKey,
|
||||||
|
url: fileUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(file: FileTypes.ProviderDeleteFileDTO): Promise<void> {
|
||||||
|
const filePath = this.getUploadFilePath(file.fileKey)
|
||||||
|
try {
|
||||||
|
await fs.access(filePath, fs.constants.F_OK)
|
||||||
|
await fs.unlink(filePath)
|
||||||
|
} catch (e) {
|
||||||
|
// The file does not exist, so it's a noop.
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPresignedDownloadUrl(
|
||||||
|
fileData: FileTypes.ProviderGetFileDTO
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
await fs.access(
|
||||||
|
this.getUploadFilePath(fileData.fileKey),
|
||||||
|
fs.constants.F_OK
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.NOT_FOUND,
|
||||||
|
`File with key ${fileData.fileKey} not found`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getUploadFileUrl(fileData.fileKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUploadFilePath = (fileKey: string) => {
|
||||||
|
return path.join(this.uploadDir_, fileKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUploadFileUrl = (fileKey: string) => {
|
||||||
|
return path.join(this.backendUrl_, this.getUploadFilePath(fileKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureDirExists(dirPath: string) {
|
||||||
|
const relativePath = path.join(this.uploadDir_, dirPath)
|
||||||
|
try {
|
||||||
|
await fs.access(relativePath, fs.constants.F_OK)
|
||||||
|
} catch (e) {
|
||||||
|
await fs.mkdir(relativePath, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"es5",
|
||||||
|
"es6",
|
||||||
|
"es2019"
|
||||||
|
],
|
||||||
|
"target": "es5",
|
||||||
|
"jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"declaration": true,
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"downlevelIteration": true, // to use ES5 specific tooling
|
||||||
|
"inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"src/**/__tests__",
|
||||||
|
"src/**/__mocks__",
|
||||||
|
"src/**/__fixtures__",
|
||||||
|
"node_modules",
|
||||||
|
".eslintrc.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { FileTypes } from "@medusajs/types"
|
||||||
|
import { AbstractFileProviderService } from "@medusajs/utils"
|
||||||
|
|
||||||
|
export class FileProviderServiceFixtures extends AbstractFileProviderService {
|
||||||
|
static identifier = "fixtures-file-provider"
|
||||||
|
protected storage = {}
|
||||||
|
async upload(
|
||||||
|
file: FileTypes.ProviderUploadFileDTO
|
||||||
|
): Promise<FileTypes.ProviderFileResultDTO> {
|
||||||
|
this.storage[file.filename] = file.content
|
||||||
|
return {
|
||||||
|
url: file.filename,
|
||||||
|
key: file.filename,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async delete(file: FileTypes.ProviderDeleteFileDTO): Promise<void> {
|
||||||
|
delete this.storage[file.fileKey]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPresignedDownloadUrl(
|
||||||
|
fileData: FileTypes.ProviderGetFileDTO
|
||||||
|
): Promise<string> {
|
||||||
|
if (this.storage[fileData.fileKey]) {
|
||||||
|
return this.storage[fileData.fileKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const services = [FileProviderServiceFixtures]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./default-provider"
|
||||||
@@ -1,5 +1,56 @@
|
|||||||
|
import { resolve } from "path"
|
||||||
|
import { Modules } from "@medusajs/utils"
|
||||||
|
import { SuiteOptions, moduleIntegrationTestRunner } from "medusa-test-utils"
|
||||||
|
import { Entity, PrimaryKey } from "@mikro-orm/core"
|
||||||
|
|
||||||
jest.setTimeout(100000)
|
jest.setTimeout(100000)
|
||||||
|
|
||||||
|
// The test runner throws if a model is not passed, so we create a dummy entity
|
||||||
|
@Entity({ tableName: "dummy_file_entity" })
|
||||||
|
export default class DummyEntity {
|
||||||
|
@PrimaryKey()
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleOptions = {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: resolve(
|
||||||
|
process.cwd() +
|
||||||
|
"/integration-tests/__fixtures__/providers/default-provider"
|
||||||
|
),
|
||||||
|
options: {
|
||||||
|
config: {
|
||||||
|
"default-provider": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleIntegrationTestRunner({
|
||||||
|
moduleName: Modules.FILE,
|
||||||
|
moduleOptions: moduleOptions,
|
||||||
|
moduleModels: [DummyEntity],
|
||||||
|
// TODO: Fix the type of service, it complains for some reason if we pass IFileModuleService
|
||||||
|
testSuite: ({ service }: SuiteOptions<any>) => {
|
||||||
describe("File Module Service", () => {
|
describe("File Module Service", () => {
|
||||||
it("noop", function () {})
|
it("creates and gets a file", async () => {
|
||||||
|
const res = await service.create({
|
||||||
|
filename: "test.jpg",
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: Buffer.from("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res).toEqual({
|
||||||
|
id: "test.jpg",
|
||||||
|
url: "test.jpg",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The fake provider returns the file content as the url
|
||||||
|
const downloadUrl = await service.retrieve("test.jpg")
|
||||||
|
expect(await new Response(downloadUrl.url).text()).toEqual("test")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import {
|
import { moduleDefinition } from "./module-definition"
|
||||||
moduleDefinition,
|
import { initializeFactory, Modules } from "@medusajs/modules-sdk"
|
||||||
revertMigration,
|
export * from "./types"
|
||||||
runMigrations,
|
|
||||||
} from "./module-definition"
|
|
||||||
|
|
||||||
export default moduleDefinition
|
|
||||||
export { revertMigration, runMigrations }
|
|
||||||
|
|
||||||
export * from "./services"
|
export * from "./services"
|
||||||
|
|
||||||
|
export const initialize = initializeFactory({
|
||||||
|
moduleName: Modules.FILE,
|
||||||
|
moduleDefinition,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const runMigrations = moduleDefinition.runMigrations
|
||||||
|
export const revertMigration = moduleDefinition.revertMigration
|
||||||
|
export default moduleDefinition
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { moduleProviderLoader } from "@medusajs/modules-sdk"
|
import { moduleProviderLoader } from "@medusajs/modules-sdk"
|
||||||
import { LoaderOptions, ModuleProvider, ModulesSdkTypes } from "@medusajs/types"
|
import { LoaderOptions, ModuleProvider, ModulesSdkTypes } from "@medusajs/types"
|
||||||
import { FileProviderService } from "@services"
|
import { FileProviderService } from "@services"
|
||||||
import { FileProviderIdentifierRegistrationName } from "@types"
|
import {
|
||||||
|
FileProviderIdentifierRegistrationName,
|
||||||
|
FileProviderRegistrationPrefix,
|
||||||
|
} from "@types"
|
||||||
import { Lifetime, asFunction, asValue } from "awilix"
|
import { Lifetime, asFunction, asValue } from "awilix"
|
||||||
|
|
||||||
const registrationFn = async (klass, container, pluginOptions) => {
|
const registrationFn = async (klass, container, pluginOptions) => {
|
||||||
@@ -9,9 +12,12 @@ const registrationFn = async (klass, container, pluginOptions) => {
|
|||||||
const key = FileProviderService.getRegistrationIdentifier(klass, name)
|
const key = FileProviderService.getRegistrationIdentifier(klass, name)
|
||||||
|
|
||||||
container.register({
|
container.register({
|
||||||
["file_" + key]: asFunction((cradle) => new klass(cradle, config), {
|
[FileProviderRegistrationPrefix + key]: asFunction(
|
||||||
|
(cradle) => new klass(cradle, config),
|
||||||
|
{
|
||||||
lifetime: klass.LIFE_TIME || Lifetime.SINGLETON,
|
lifetime: klass.LIFE_TIME || Lifetime.SINGLETON,
|
||||||
}),
|
}
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
container.registerAdd(FileProviderIdentifierRegistrationName, asValue(key))
|
container.registerAdd(FileProviderIdentifierRegistrationName, asValue(key))
|
||||||
@@ -25,16 +31,11 @@ export default async ({
|
|||||||
(
|
(
|
||||||
| ModulesSdkTypes.ModuleServiceInitializeOptions
|
| ModulesSdkTypes.ModuleServiceInitializeOptions
|
||||||
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
||||||
) & { provider: ModuleProvider }
|
) & { providers: ModuleProvider[] }
|
||||||
>): Promise<void> => {
|
>): Promise<void> => {
|
||||||
container.registerAdd(
|
|
||||||
FileProviderIdentifierRegistrationName,
|
|
||||||
asValue(undefined)
|
|
||||||
)
|
|
||||||
|
|
||||||
await moduleProviderLoader({
|
await moduleProviderLoader({
|
||||||
container,
|
container,
|
||||||
providers: options?.provider ? [options?.provider] : [],
|
providers: options?.providers || [],
|
||||||
registerServiceFn: registrationFn,
|
registerServiceFn: registrationFn,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ModuleExports } from "@medusajs/types"
|
import { ModuleExports } from "@medusajs/types"
|
||||||
import { FileModuleService } from "@services"
|
import { FileModuleService } from "@services"
|
||||||
import loadProviders from "./loaders/providers"
|
import loadProviders from "./loaders/providers"
|
||||||
|
import * as ModuleServices from "@services"
|
||||||
|
import { ModulesSdkUtils } from "@medusajs/utils"
|
||||||
|
|
||||||
export const runMigrations = () => {
|
export const runMigrations = () => {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
@@ -9,9 +11,15 @@ export const revertMigration = () => {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = FileModuleService
|
const containerLoader = ModulesSdkUtils.moduleContainerLoaderFactory({
|
||||||
const loaders = [loadProviders] as any
|
moduleModels: {},
|
||||||
|
moduleRepositories: {},
|
||||||
|
moduleServices: ModuleServices,
|
||||||
|
})
|
||||||
|
|
||||||
|
const loaders = [containerLoader, loadProviders] as any
|
||||||
|
|
||||||
|
const service = FileModuleService
|
||||||
export const moduleDefinition: ModuleExports = {
|
export const moduleDefinition: ModuleExports = {
|
||||||
service,
|
service,
|
||||||
loaders,
|
loaders,
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ import {
|
|||||||
CreateFileDTO,
|
CreateFileDTO,
|
||||||
FileDTO,
|
FileDTO,
|
||||||
ModuleJoinerConfig,
|
ModuleJoinerConfig,
|
||||||
|
FileTypes,
|
||||||
|
FilterableFileProps,
|
||||||
|
FindConfig,
|
||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
|
|
||||||
import { joinerConfig } from "../joiner-config"
|
import { joinerConfig } from "../joiner-config"
|
||||||
import FileProviderService from "./file-provider-service"
|
import FileProviderService from "./file-provider-service"
|
||||||
|
import { MedusaError } from "medusa-core-utils"
|
||||||
|
|
||||||
type InjectedDependencies = {
|
type InjectedDependencies = {
|
||||||
fileProviderService: FileProviderService
|
fileProviderService: FileProviderService
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class FileModuleService {
|
export default class FileModuleService implements FileTypes.IFileModuleService {
|
||||||
protected readonly fileProviderService_: FileProviderService
|
protected readonly fileProviderService_: FileProviderService
|
||||||
constructor({ fileProviderService }: InjectedDependencies) {
|
constructor({ fileProviderService }: InjectedDependencies) {
|
||||||
this.fileProviderService_ = fileProviderService
|
this.fileProviderService_ = fileProviderService
|
||||||
@@ -51,7 +55,6 @@ export default class FileModuleService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieve(id: string): Promise<FileDTO>
|
|
||||||
async retrieve(id: string): Promise<FileDTO> {
|
async retrieve(id: string): Promise<FileDTO> {
|
||||||
const res = await this.fileProviderService_.getPresignedDownloadUrl({
|
const res = await this.fileProviderService_.getPresignedDownloadUrl({
|
||||||
fileKey: id,
|
fileKey: id,
|
||||||
@@ -62,4 +65,65 @@ export default class FileModuleService {
|
|||||||
url: res,
|
url: res,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
filters?: FilterableFileProps,
|
||||||
|
config?: FindConfig<FileDTO>,
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<FileDTO[]> {
|
||||||
|
const id = Array.isArray(filters?.id) ? filters?.id?.[0] : filters?.id
|
||||||
|
if (!id) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
"Listing of files is only supported when filtering by ID."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await this.fileProviderService_.getPresignedDownloadUrl({
|
||||||
|
fileKey: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
url: res,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAndCount(
|
||||||
|
filters?: FilterableFileProps,
|
||||||
|
config?: FindConfig<FileDTO>,
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<[FileDTO[], number]> {
|
||||||
|
const id = Array.isArray(filters?.id) ? filters?.id?.[0] : filters?.id
|
||||||
|
if (!id) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
"Listing and counting of files is only supported when filtering by ID."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await this.fileProviderService_.getPresignedDownloadUrl({
|
||||||
|
fileKey: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return [[], 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
url: res,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
import { Constructor, DAL, FileTypes } from "@medusajs/types"
|
import { Constructor, DAL, FileTypes } from "@medusajs/types"
|
||||||
import { MedusaError } from "medusa-core-utils"
|
import { MedusaError } from "medusa-core-utils"
|
||||||
|
import { FileProviderRegistrationPrefix } from "@types"
|
||||||
|
|
||||||
type InjectedDependencies = {
|
type InjectedDependencies = {
|
||||||
[key: `file_${string}`]: FileTypes.IFileProvider
|
[
|
||||||
|
key: `${typeof FileProviderRegistrationPrefix}${string}`
|
||||||
|
]: FileTypes.IFileProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class FileProviderService {
|
export default class FileProviderService {
|
||||||
protected readonly fileProvider_: FileTypes.IFileProvider
|
protected readonly fileProvider_: FileTypes.IFileProvider
|
||||||
|
|
||||||
constructor(container: InjectedDependencies) {
|
constructor(container: InjectedDependencies) {
|
||||||
if (Object.keys(container).length !== 1) {
|
const fileProviderKeys = Object.keys(container).filter((k) =>
|
||||||
|
k.startsWith(FileProviderRegistrationPrefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fileProviderKeys.length !== 1) {
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
MedusaError.Types.INVALID_DATA,
|
MedusaError.Types.INVALID_DATA,
|
||||||
`File module should only be initialized with one provider`
|
`File module should be initialized with exactly one provider`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.fileProvider_ = Object.values(container)[0]
|
this.fileProvider_ = container[fileProviderKeys[0]]
|
||||||
}
|
}
|
||||||
|
|
||||||
static getRegistrationIdentifier(
|
static getRegistrationIdentifier(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
export const FileProviderIdentifierRegistrationName =
|
export const FileProviderIdentifierRegistrationName =
|
||||||
"file_providers_identifier"
|
"file_providers_identifier"
|
||||||
|
|
||||||
|
export const FileProviderRegistrationPrefix = "fs_"
|
||||||
|
|
||||||
export type FileModuleOptions = Partial<ModuleServiceInitializeOptions> & {
|
export type FileModuleOptions = Partial<ModuleServiceInitializeOptions> & {
|
||||||
/**
|
/**
|
||||||
* Providers to be registered
|
* Providers to be registered
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
MedusaResponse,
|
MedusaResponse,
|
||||||
} from "../../../../types/routing"
|
} from "../../../../types/routing"
|
||||||
import { deleteFilesWorkflow } from "@medusajs/core-flows"
|
import { deleteFilesWorkflow } from "@medusajs/core-flows"
|
||||||
import { ContainerRegistrationKeys } from "@medusajs/utils"
|
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/utils"
|
||||||
import { remoteQueryObjectFromString } from "@medusajs/utils"
|
import { remoteQueryObjectFromString } from "@medusajs/utils"
|
||||||
|
|
||||||
export const GET = async (
|
export const GET = async (
|
||||||
@@ -20,6 +20,13 @@ export const GET = async (
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [file] = await remoteQuery(queryObject)
|
const [file] = await remoteQuery(queryObject)
|
||||||
|
if (!file) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.NOT_FOUND,
|
||||||
|
`File with id: ${req.params.id} not found`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json({ file })
|
res.status(200).json({ file })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import multer from "multer"
|
import multer from "multer"
|
||||||
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
||||||
import { authenticate } from "../../../utils/authenticate-middleware"
|
import { authenticate } from "../../../utils/authenticate-middleware"
|
||||||
|
import { validateAndTransformQuery } from "../../utils/validate-query"
|
||||||
|
import { retrieveUploadConfig } from "./query-config"
|
||||||
|
import { AdminGetUploadParams } from "./validators"
|
||||||
|
|
||||||
const upload = multer({ dest: "uploads/" })
|
// 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 adminUploadRoutesMiddlewares: MiddlewareRoute[] = [
|
export const adminUploadRoutesMiddlewares: MiddlewareRoute[] = [
|
||||||
{
|
{
|
||||||
@@ -10,16 +16,21 @@ export const adminUploadRoutesMiddlewares: MiddlewareRoute[] = [
|
|||||||
matcher: "/admin/uploads*",
|
matcher: "/admin/uploads*",
|
||||||
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
|
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: ["GET"],
|
|
||||||
matcher: "/admin/uploads/:id",
|
|
||||||
middlewares: [],
|
|
||||||
},
|
|
||||||
// TODO: There is a `/protected` route in v1 that might need a bit more thought when implementing
|
// TODO: There is a `/protected` route in v1 that might need a bit more thought when implementing
|
||||||
{
|
{
|
||||||
method: ["POST"],
|
method: ["POST"],
|
||||||
matcher: "/admin/uploads",
|
matcher: "/admin/uploads",
|
||||||
middlewares: [upload.array("files")],
|
middlewares: [
|
||||||
|
upload.array("files"),
|
||||||
|
validateAndTransformQuery(AdminGetUploadParams, retrieveUploadConfig),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: ["GET"],
|
||||||
|
matcher: "/admin/uploads/:id",
|
||||||
|
middlewares: [
|
||||||
|
validateAndTransformQuery(AdminGetUploadParams, retrieveUploadConfig),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: ["DELETE"],
|
method: ["DELETE"],
|
||||||
|
|||||||
@@ -4,18 +4,27 @@ import {
|
|||||||
AuthenticatedMedusaRequest,
|
AuthenticatedMedusaRequest,
|
||||||
MedusaResponse,
|
MedusaResponse,
|
||||||
} from "../../../types/routing"
|
} from "../../../types/routing"
|
||||||
|
import { MedusaError } from "@medusajs/utils"
|
||||||
|
|
||||||
export const POST = async (
|
export const POST = async (
|
||||||
req: AuthenticatedMedusaRequest<CreateProductDTO>,
|
req: AuthenticatedMedusaRequest<CreateProductDTO>,
|
||||||
res: MedusaResponse
|
res: MedusaResponse
|
||||||
) => {
|
) => {
|
||||||
const input = req.files as any[]
|
const input = req.files as Express.Multer.File[]
|
||||||
|
|
||||||
|
if (!input?.length) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
"No files were uploaded"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const { result, errors } = await uploadFilesWorkflow(req.scope).run({
|
const { result, errors } = await uploadFilesWorkflow(req.scope).run({
|
||||||
input: {
|
input: {
|
||||||
files: input?.map((f) => ({
|
files: input?.map((f) => ({
|
||||||
filename: "test",
|
filename: f.originalname,
|
||||||
mimeType: "image/jpeg",
|
mimeType: f.mimetype,
|
||||||
content: f.buffer,
|
content: f.buffer.toString("binary"),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
@@ -24,5 +33,6 @@ export const POST = async (
|
|||||||
if (Array.isArray(errors) && errors[0]) {
|
if (Array.isArray(errors) && errors[0]) {
|
||||||
throw errors[0].error
|
throw errors[0].error
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ files: result })
|
res.status(200).json({ files: result })
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createSelectParams } from "../../utils/validators"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export type AdminGetUploadParamsType = z.infer<typeof AdminGetUploadParams>
|
||||||
|
export const AdminGetUploadParams = createSelectParams()
|
||||||
@@ -11,3 +11,15 @@ export interface FileDTO {
|
|||||||
*/
|
*/
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @interface
|
||||||
|
*
|
||||||
|
* Filters to apply on a currency.
|
||||||
|
*/
|
||||||
|
export interface FilterableFileProps {
|
||||||
|
/**
|
||||||
|
* The file ID to filter by.
|
||||||
|
*/
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export interface CreateFileDTO {
|
|||||||
mimeType: string
|
mimeType: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The file content
|
* The file content as a binary-encoded string
|
||||||
*/
|
*/
|
||||||
content: Blob
|
content: string
|
||||||
}
|
}
|
||||||
@@ -63,9 +63,9 @@ export type ProviderUploadFileDTO = {
|
|||||||
mimeType: string
|
mimeType: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The file content
|
* The file content as a binary-encoded string
|
||||||
*/
|
*/
|
||||||
content: Blob
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IModuleService } from "../modules-sdk"
|
import { IModuleService } from "../modules-sdk"
|
||||||
import { FileDTO } from "./common"
|
import { FileDTO, FilterableFileProps } 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 } from "./mutations"
|
||||||
@@ -82,4 +82,38 @@ export interface IFileModuleService extends IModuleService {
|
|||||||
config?: FindConfig<FileDTO>,
|
config?: FindConfig<FileDTO>,
|
||||||
sharedContext?: Context
|
sharedContext?: Context
|
||||||
): Promise<FileDTO>
|
): Promise<FileDTO>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is used to retrieve a file by ID, similarly to `retrieve`. Enumeration of files is not supported, but the list method is in order to support remote queries
|
||||||
|
*
|
||||||
|
* @param {FilterableFileProps} filters - The filters to apply on the retrieved files.
|
||||||
|
* @param {FindConfig<FileDTO>} config -
|
||||||
|
* The configurations determining how the files are retrieved. Its properties, such as `select` or `relations`, accept the
|
||||||
|
* attributes or relations associated with a file.
|
||||||
|
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
|
||||||
|
* @returns {Promise<FileDTO[]>} The list of files. In this particular case, it will either be at most one file.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
list(
|
||||||
|
filters?: FilterableFileProps,
|
||||||
|
config?: FindConfig<FileDTO>,
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<FileDTO[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is used to retrieve a file by ID, similarly to `retrieve`. Enumeration of files is not supported, but the listAndCount method is in order to support remote queries
|
||||||
|
*
|
||||||
|
* @param {FilterableFileProps} filters - The filters to apply on the retrieved files.
|
||||||
|
* @param {FindConfig<FileDTO>} config -
|
||||||
|
* The configurations determining how the files are retrieved. Its properties, such as `select` or `relations`, accept the
|
||||||
|
* attributes or relations associated with a file.
|
||||||
|
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
|
||||||
|
* @returns {Promise<[FileDTO[], number]>} The list of files and their count. In this particular case, it will either be at most one file.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
listAndCount(
|
||||||
|
filters?: FilterableFileProps,
|
||||||
|
config?: FindConfig<FileDTO>,
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<[FileDTO[], number]>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { FileTypes, IFileProvider } from "@medusajs/types"
|
||||||
|
|
||||||
|
export class AbstractFileProviderService implements IFileProvider {
|
||||||
|
static identifier: string
|
||||||
|
|
||||||
|
getIdentifier() {
|
||||||
|
return (this.constructor as any).identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(
|
||||||
|
file: FileTypes.ProviderUploadFileDTO
|
||||||
|
): Promise<FileTypes.ProviderFileResultDTO> {
|
||||||
|
throw Error("upload must be overridden by the child class")
|
||||||
|
}
|
||||||
|
async delete(file: FileTypes.ProviderDeleteFileDTO): Promise<void> {
|
||||||
|
throw Error("delete must be overridden by the child class")
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPresignedDownloadUrl(
|
||||||
|
fileData: FileTypes.ProviderGetFileDTO
|
||||||
|
): Promise<string> {
|
||||||
|
throw Error("getPresignedDownloadUrl must be overridden by the child class")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./abstract-file-provider"
|
||||||
@@ -23,5 +23,6 @@ export * from "./totals/big-number"
|
|||||||
export * from "./user"
|
export * from "./user"
|
||||||
export * from "./api-key"
|
export * from "./api-key"
|
||||||
export * from "./link"
|
export * from "./link"
|
||||||
|
export * from "./file"
|
||||||
|
|
||||||
export const MedusaModuleType = Symbol.for("MedusaModule")
|
export const MedusaModuleType = Symbol.for("MedusaModule")
|
||||||
@@ -21,4 +21,5 @@ export enum Modules {
|
|||||||
API_KEY = "apiKey",
|
API_KEY = "apiKey",
|
||||||
STORE = "store",
|
STORE = "store",
|
||||||
CURRENCY = "currency",
|
CURRENCY = "currency",
|
||||||
|
FILE = "file",
|
||||||
}
|
}
|
||||||
@@ -8231,6 +8231,18 @@ __metadata:
|
|||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
"@medusajs/file-local-next@workspace:packages/file-local":
|
||||||
|
version: 0.0.0-use.local
|
||||||
|
resolution: "@medusajs/file-local-next@workspace:packages/file-local"
|
||||||
|
dependencies:
|
||||||
|
"@medusajs/utils": ^1.11.7
|
||||||
|
cross-env: ^5.2.1
|
||||||
|
jest: ^25.5.4
|
||||||
|
rimraf: ^5.0.1
|
||||||
|
typescript: ^4.9.5
|
||||||
|
languageName: unknown
|
||||||
|
linkType: soft
|
||||||
|
|
||||||
"@medusajs/file-local@workspace:packages/medusa-file-local":
|
"@medusajs/file-local@workspace:packages/medusa-file-local":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@medusajs/file-local@workspace:packages/medusa-file-local"
|
resolution: "@medusajs/file-local@workspace:packages/medusa-file-local"
|
||||||
@@ -32100,6 +32112,7 @@ __metadata:
|
|||||||
"@swc/jest": ^0.2.36
|
"@swc/jest": ^0.2.36
|
||||||
babel-preset-medusa-package: "*"
|
babel-preset-medusa-package: "*"
|
||||||
faker: ^5.5.3
|
faker: ^5.5.3
|
||||||
|
form-data: ^4.0.0
|
||||||
jest: ^26.6.3
|
jest: ^26.6.3
|
||||||
jest-environment-node: 26.6.2
|
jest-environment-node: 26.6.2
|
||||||
medusa-fulfillment-webshipper: "workspace:*"
|
medusa-fulfillment-webshipper: "workspace:*"
|
||||||
|
|||||||
Reference in new issue
Block a user