From 614d659a593c9926ca668dd5101806c87f55009b Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Wed, 24 Apr 2024 10:59:58 +0200 Subject: [PATCH] feat: Add local file provider and wire everything up in the file module (#7134) --- .../api/__tests__/admin/upload.ts | 125 ++++++++++++++++++ integration-tests/api/medusa-config.js | 15 +++ integration-tests/api/package.json | 1 + .../core-flows/src/file/steps/upload-files.ts | 2 +- .../src/file/workflows/upload-files.ts | 2 +- packages/file-local/.gitignore | 4 + packages/file-local/README.md | 0 packages/file-local/jest.config.js | 13 ++ packages/file-local/package.json | 38 ++++++ packages/file-local/src/index.ts | 10 ++ .../file-local/src/services/local-file.ts | 100 ++++++++++++++ packages/file-local/tsconfig.json | 36 +++++ packages/file-local/tsconfig.spec.json | 5 + .../providers/default-provider.ts | 32 +++++ .../__fixtures__/providers/index.ts | 1 + .../__tests__/module.spec.ts | 55 +++++++- packages/file/src/index.ts | 21 +-- packages/file/src/loaders/providers.ts | 23 ++-- packages/file/src/module-definition.ts | 12 +- .../file/src/services/file-module-service.ts | 68 +++++++++- .../src/services/file-provider-service.ts | 15 ++- packages/file/src/types/index.ts | 2 + .../src/api-v2/admin/uploads/[id]/route.ts | 9 +- .../src/api-v2/admin/uploads/middlewares.ts | 25 +++- .../medusa/src/api-v2/admin/uploads/route.ts | 18 ++- .../src/api-v2/admin/uploads/validators.ts | 5 + packages/types/src/file/common.ts | 12 ++ packages/types/src/file/mutations.ts | 4 +- packages/types/src/file/provider.ts | 4 +- packages/types/src/file/service.ts | 36 ++++- .../utils/src/file/abstract-file-provider.ts | 24 ++++ packages/utils/src/file/index.ts | 1 + packages/utils/src/index.ts | 1 + packages/utils/src/modules-sdk/definition.ts | 1 + yarn.lock | 13 ++ 35 files changed, 684 insertions(+), 49 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/upload.ts create mode 100644 packages/file-local/.gitignore create mode 100644 packages/file-local/README.md create mode 100644 packages/file-local/jest.config.js create mode 100644 packages/file-local/package.json create mode 100644 packages/file-local/src/index.ts create mode 100644 packages/file-local/src/services/local-file.ts create mode 100644 packages/file-local/tsconfig.json create mode 100644 packages/file-local/tsconfig.spec.json create mode 100644 packages/file/integration-tests/__fixtures__/providers/default-provider.ts create mode 100644 packages/file/integration-tests/__fixtures__/providers/index.ts create mode 100644 packages/medusa/src/api-v2/admin/uploads/validators.ts create mode 100644 packages/utils/src/file/abstract-file-provider.ts create mode 100644 packages/utils/src/file/index.ts diff --git a/integration-tests/api/__tests__/admin/upload.ts b/integration-tests/api/__tests__/admin/upload.ts new file mode 100644 index 0000000000..968a14dad1 --- /dev/null +++ b/integration-tests/api/__tests__/admin/upload.ts @@ -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`) + }) + }) + }, +}) diff --git a/integration-tests/api/medusa-config.js b/integration-tests/api/medusa-config.js index 1aee8b9719..a0ea9b81ea 100644 --- a/integration-tests/api/medusa-config.js +++ b/integration-tests/api/medusa-config.js @@ -75,6 +75,21 @@ module.exports = { resolve: "@medusajs/inventory-next", options: {}, }, + [Modules.FILE]: { + resolve: "@medusajs/file", + options: { + providers: [ + { + resolve: "@medusajs/file-local-next", + options: { + config: { + local: {}, + }, + }, + }, + ], + }, + }, [Modules.PRODUCT]: true, [Modules.PRICING]: true, [Modules.PROMOTION]: true, diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index 6445e1fd4b..79d19202f3 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -41,6 +41,7 @@ "@swc/core": "^1.4.8", "@swc/jest": "^0.2.36", "babel-preset-medusa-package": "*", + "form-data": "^4.0.0", "jest": "^26.6.3", "jest-environment-node": "26.6.2" } diff --git a/packages/core-flows/src/file/steps/upload-files.ts b/packages/core-flows/src/file/steps/upload-files.ts index c1a9cfc016..671c5e9074 100644 --- a/packages/core-flows/src/file/steps/upload-files.ts +++ b/packages/core-flows/src/file/steps/upload-files.ts @@ -6,7 +6,7 @@ type UploadFilesStepInput = { files: { filename: string mimeType: string - content: Blob + content: string }[] } diff --git a/packages/core-flows/src/file/workflows/upload-files.ts b/packages/core-flows/src/file/workflows/upload-files.ts index be3884d336..f8751e879e 100644 --- a/packages/core-flows/src/file/workflows/upload-files.ts +++ b/packages/core-flows/src/file/workflows/upload-files.ts @@ -6,7 +6,7 @@ type WorkflowInput = { files: { filename: string mimeType: string - content: Blob + content: string }[] } diff --git a/packages/file-local/.gitignore b/packages/file-local/.gitignore new file mode 100644 index 0000000000..83cb36a41e --- /dev/null +++ b/packages/file-local/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +.DS_store +yarn.lock diff --git a/packages/file-local/README.md b/packages/file-local/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/file-local/jest.config.js b/packages/file-local/jest.config.js new file mode 100644 index 0000000000..e564d67c70 --- /dev/null +++ b/packages/file-local/jest.config.js @@ -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`], +} diff --git a/packages/file-local/package.json b/packages/file-local/package.json new file mode 100644 index 0000000000..b52b257a74 --- /dev/null +++ b/packages/file-local/package.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" + ] +} diff --git a/packages/file-local/src/index.ts b/packages/file-local/src/index.ts new file mode 100644 index 0000000000..50180f56a4 --- /dev/null +++ b/packages/file-local/src/index.ts @@ -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 diff --git a/packages/file-local/src/services/local-file.ts b/packages/file-local/src/services/local-file.ts new file mode 100644 index 0000000000..6fe0937888 --- /dev/null +++ b/packages/file-local/src/services/local-file.ts @@ -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 { + 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 { + 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 { + 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 }) + } + } +} diff --git a/packages/file-local/tsconfig.json b/packages/file-local/tsconfig.json new file mode 100644 index 0000000000..65e5a4fd5b --- /dev/null +++ b/packages/file-local/tsconfig.json @@ -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" + ] +} diff --git a/packages/file-local/tsconfig.spec.json b/packages/file-local/tsconfig.spec.json new file mode 100644 index 0000000000..9b62409191 --- /dev/null +++ b/packages/file-local/tsconfig.spec.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/file/integration-tests/__fixtures__/providers/default-provider.ts b/packages/file/integration-tests/__fixtures__/providers/default-provider.ts new file mode 100644 index 0000000000..8b6858f623 --- /dev/null +++ b/packages/file/integration-tests/__fixtures__/providers/default-provider.ts @@ -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 { + this.storage[file.filename] = file.content + return { + url: file.filename, + key: file.filename, + } + } + async delete(file: FileTypes.ProviderDeleteFileDTO): Promise { + delete this.storage[file.fileKey] + return + } + + async getPresignedDownloadUrl( + fileData: FileTypes.ProviderGetFileDTO + ): Promise { + if (this.storage[fileData.fileKey]) { + return this.storage[fileData.fileKey] + } + + return "" + } +} + +export const services = [FileProviderServiceFixtures] diff --git a/packages/file/integration-tests/__fixtures__/providers/index.ts b/packages/file/integration-tests/__fixtures__/providers/index.ts new file mode 100644 index 0000000000..e19230b5b7 --- /dev/null +++ b/packages/file/integration-tests/__fixtures__/providers/index.ts @@ -0,0 +1 @@ +export * from "./default-provider" diff --git a/packages/file/integration-tests/__tests__/module.spec.ts b/packages/file/integration-tests/__tests__/module.spec.ts index 1add347987..50c3f324fb 100644 --- a/packages/file/integration-tests/__tests__/module.spec.ts +++ b/packages/file/integration-tests/__tests__/module.spec.ts @@ -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) -describe("File Module Service", () => { - it("noop", function () {}) +// 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) => { + describe("File Module Service", () => { + 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") + }) + }) + }, }) diff --git a/packages/file/src/index.ts b/packages/file/src/index.ts index 0cc6cb0213..8bbb7fbf90 100644 --- a/packages/file/src/index.ts +++ b/packages/file/src/index.ts @@ -1,10 +1,13 @@ -import { - moduleDefinition, - revertMigration, - runMigrations, -} from "./module-definition" - -export default moduleDefinition -export { revertMigration, runMigrations } - +import { moduleDefinition } from "./module-definition" +import { initializeFactory, Modules } from "@medusajs/modules-sdk" +export * from "./types" export * from "./services" + +export const initialize = initializeFactory({ + moduleName: Modules.FILE, + moduleDefinition, +}) + +export const runMigrations = moduleDefinition.runMigrations +export const revertMigration = moduleDefinition.revertMigration +export default moduleDefinition diff --git a/packages/file/src/loaders/providers.ts b/packages/file/src/loaders/providers.ts index eb5411784e..926eafa174 100644 --- a/packages/file/src/loaders/providers.ts +++ b/packages/file/src/loaders/providers.ts @@ -1,7 +1,10 @@ import { moduleProviderLoader } from "@medusajs/modules-sdk" import { LoaderOptions, ModuleProvider, ModulesSdkTypes } from "@medusajs/types" import { FileProviderService } from "@services" -import { FileProviderIdentifierRegistrationName } from "@types" +import { + FileProviderIdentifierRegistrationName, + FileProviderRegistrationPrefix, +} from "@types" import { Lifetime, asFunction, asValue } from "awilix" const registrationFn = async (klass, container, pluginOptions) => { @@ -9,9 +12,12 @@ const registrationFn = async (klass, container, pluginOptions) => { const key = FileProviderService.getRegistrationIdentifier(klass, name) container.register({ - ["file_" + key]: asFunction((cradle) => new klass(cradle, config), { - lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, - }), + [FileProviderRegistrationPrefix + key]: asFunction( + (cradle) => new klass(cradle, config), + { + lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, + } + ), }) container.registerAdd(FileProviderIdentifierRegistrationName, asValue(key)) @@ -25,16 +31,11 @@ export default async ({ ( | ModulesSdkTypes.ModuleServiceInitializeOptions | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions - ) & { provider: ModuleProvider } + ) & { providers: ModuleProvider[] } >): Promise => { - container.registerAdd( - FileProviderIdentifierRegistrationName, - asValue(undefined) - ) - await moduleProviderLoader({ container, - providers: options?.provider ? [options?.provider] : [], + providers: options?.providers || [], registerServiceFn: registrationFn, }) } diff --git a/packages/file/src/module-definition.ts b/packages/file/src/module-definition.ts index bee485d071..c82f15d05a 100644 --- a/packages/file/src/module-definition.ts +++ b/packages/file/src/module-definition.ts @@ -1,6 +1,8 @@ import { ModuleExports } from "@medusajs/types" import { FileModuleService } from "@services" import loadProviders from "./loaders/providers" +import * as ModuleServices from "@services" +import { ModulesSdkUtils } from "@medusajs/utils" export const runMigrations = () => { return Promise.resolve() @@ -9,9 +11,15 @@ export const revertMigration = () => { return Promise.resolve() } -const service = FileModuleService -const loaders = [loadProviders] as any +const containerLoader = ModulesSdkUtils.moduleContainerLoaderFactory({ + moduleModels: {}, + moduleRepositories: {}, + moduleServices: ModuleServices, +}) +const loaders = [containerLoader, loadProviders] as any + +const service = FileModuleService export const moduleDefinition: ModuleExports = { service, loaders, diff --git a/packages/file/src/services/file-module-service.ts b/packages/file/src/services/file-module-service.ts index ff7f48ab06..633ea77dcb 100644 --- a/packages/file/src/services/file-module-service.ts +++ b/packages/file/src/services/file-module-service.ts @@ -3,16 +3,20 @@ import { CreateFileDTO, FileDTO, ModuleJoinerConfig, + FileTypes, + FilterableFileProps, + FindConfig, } from "@medusajs/types" import { joinerConfig } from "../joiner-config" import FileProviderService from "./file-provider-service" +import { MedusaError } from "medusa-core-utils" type InjectedDependencies = { fileProviderService: FileProviderService } -export default class FileModuleService { +export default class FileModuleService implements FileTypes.IFileModuleService { protected readonly fileProviderService_: FileProviderService constructor({ fileProviderService }: InjectedDependencies) { this.fileProviderService_ = fileProviderService @@ -51,7 +55,6 @@ export default class FileModuleService { return } - async retrieve(id: string): Promise async retrieve(id: string): Promise { const res = await this.fileProviderService_.getPresignedDownloadUrl({ fileKey: id, @@ -62,4 +65,65 @@ export default class FileModuleService { url: res, } } + + async list( + filters?: FilterableFileProps, + config?: FindConfig, + sharedContext?: Context + ): Promise { + 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, + 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, + ] + } } diff --git a/packages/file/src/services/file-provider-service.ts b/packages/file/src/services/file-provider-service.ts index c4a291de83..0f6adffd80 100644 --- a/packages/file/src/services/file-provider-service.ts +++ b/packages/file/src/services/file-provider-service.ts @@ -1,22 +1,29 @@ import { Constructor, DAL, FileTypes } from "@medusajs/types" import { MedusaError } from "medusa-core-utils" +import { FileProviderRegistrationPrefix } from "@types" type InjectedDependencies = { - [key: `file_${string}`]: FileTypes.IFileProvider + [ + key: `${typeof FileProviderRegistrationPrefix}${string}` + ]: FileTypes.IFileProvider } export default class FileProviderService { protected readonly fileProvider_: FileTypes.IFileProvider 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( 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( diff --git a/packages/file/src/types/index.ts b/packages/file/src/types/index.ts index 9527a7bdc9..2f215aba19 100644 --- a/packages/file/src/types/index.ts +++ b/packages/file/src/types/index.ts @@ -6,6 +6,8 @@ import { export const FileProviderIdentifierRegistrationName = "file_providers_identifier" +export const FileProviderRegistrationPrefix = "fs_" + export type FileModuleOptions = Partial & { /** * Providers to be registered diff --git a/packages/medusa/src/api-v2/admin/uploads/[id]/route.ts b/packages/medusa/src/api-v2/admin/uploads/[id]/route.ts index aa1b800fe5..f88d1cb310 100644 --- a/packages/medusa/src/api-v2/admin/uploads/[id]/route.ts +++ b/packages/medusa/src/api-v2/admin/uploads/[id]/route.ts @@ -3,7 +3,7 @@ import { MedusaResponse, } from "../../../../types/routing" import { deleteFilesWorkflow } from "@medusajs/core-flows" -import { ContainerRegistrationKeys } from "@medusajs/utils" +import { ContainerRegistrationKeys, MedusaError } from "@medusajs/utils" import { remoteQueryObjectFromString } from "@medusajs/utils" export const GET = async ( @@ -20,6 +20,13 @@ export const GET = async ( }) 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 }) } diff --git a/packages/medusa/src/api-v2/admin/uploads/middlewares.ts b/packages/medusa/src/api-v2/admin/uploads/middlewares.ts index bad0080cf8..74a30ca058 100644 --- a/packages/medusa/src/api-v2/admin/uploads/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/uploads/middlewares.ts @@ -1,8 +1,14 @@ import multer from "multer" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" 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[] = [ { @@ -10,16 +16,21 @@ export const adminUploadRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/uploads*", 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 { method: ["POST"], 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"], diff --git a/packages/medusa/src/api-v2/admin/uploads/route.ts b/packages/medusa/src/api-v2/admin/uploads/route.ts index f52f4c5885..0941b9430d 100644 --- a/packages/medusa/src/api-v2/admin/uploads/route.ts +++ b/packages/medusa/src/api-v2/admin/uploads/route.ts @@ -4,18 +4,27 @@ import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../types/routing" +import { MedusaError } from "@medusajs/utils" export const POST = async ( req: AuthenticatedMedusaRequest, 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({ input: { files: input?.map((f) => ({ - filename: "test", - mimeType: "image/jpeg", - content: f.buffer, + filename: f.originalname, + mimeType: f.mimetype, + content: f.buffer.toString("binary"), })), }, throwOnError: false, @@ -24,5 +33,6 @@ export const POST = async ( if (Array.isArray(errors) && errors[0]) { throw errors[0].error } + res.status(200).json({ files: result }) } diff --git a/packages/medusa/src/api-v2/admin/uploads/validators.ts b/packages/medusa/src/api-v2/admin/uploads/validators.ts new file mode 100644 index 0000000000..d3773066ce --- /dev/null +++ b/packages/medusa/src/api-v2/admin/uploads/validators.ts @@ -0,0 +1,5 @@ +import { createSelectParams } from "../../utils/validators" +import { z } from "zod" + +export type AdminGetUploadParamsType = z.infer +export const AdminGetUploadParams = createSelectParams() diff --git a/packages/types/src/file/common.ts b/packages/types/src/file/common.ts index 4d7ee4542b..4042a2a50d 100644 --- a/packages/types/src/file/common.ts +++ b/packages/types/src/file/common.ts @@ -11,3 +11,15 @@ export interface FileDTO { */ url: string } + +/** + * @interface + * + * Filters to apply on a currency. + */ +export interface FilterableFileProps { + /** + * The file ID to filter by. + */ + id?: string +} diff --git a/packages/types/src/file/mutations.ts b/packages/types/src/file/mutations.ts index 574a6243c4..14a772799d 100644 --- a/packages/types/src/file/mutations.ts +++ b/packages/types/src/file/mutations.ts @@ -13,7 +13,7 @@ export interface CreateFileDTO { mimeType: string /** - * The file content + * The file content as a binary-encoded string */ - content: Blob + content: string } diff --git a/packages/types/src/file/provider.ts b/packages/types/src/file/provider.ts index 074b6f189a..9a31bef00f 100644 --- a/packages/types/src/file/provider.ts +++ b/packages/types/src/file/provider.ts @@ -63,9 +63,9 @@ export type ProviderUploadFileDTO = { mimeType: string /** - * The file content + * The file content as a binary-encoded string */ - content: Blob + content: string } /** diff --git a/packages/types/src/file/service.ts b/packages/types/src/file/service.ts index 64e088d28f..190926bf38 100644 --- a/packages/types/src/file/service.ts +++ b/packages/types/src/file/service.ts @@ -1,5 +1,5 @@ import { IModuleService } from "../modules-sdk" -import { FileDTO } from "./common" +import { FileDTO, FilterableFileProps } from "./common" import { FindConfig } from "../common" import { Context } from "../shared-context" import { CreateFileDTO } from "./mutations" @@ -82,4 +82,38 @@ export interface IFileModuleService extends IModuleService { config?: FindConfig, sharedContext?: Context ): Promise + + /** + * 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} 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} The list of files. In this particular case, it will either be at most one file. + * + */ + list( + filters?: FilterableFileProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * 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} 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, + sharedContext?: Context + ): Promise<[FileDTO[], number]> } diff --git a/packages/utils/src/file/abstract-file-provider.ts b/packages/utils/src/file/abstract-file-provider.ts new file mode 100644 index 0000000000..2aa27440c3 --- /dev/null +++ b/packages/utils/src/file/abstract-file-provider.ts @@ -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 { + throw Error("upload must be overridden by the child class") + } + async delete(file: FileTypes.ProviderDeleteFileDTO): Promise { + throw Error("delete must be overridden by the child class") + } + + async getPresignedDownloadUrl( + fileData: FileTypes.ProviderGetFileDTO + ): Promise { + throw Error("getPresignedDownloadUrl must be overridden by the child class") + } +} diff --git a/packages/utils/src/file/index.ts b/packages/utils/src/file/index.ts new file mode 100644 index 0000000000..0968b75614 --- /dev/null +++ b/packages/utils/src/file/index.ts @@ -0,0 +1 @@ +export * from "./abstract-file-provider" diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 649d69d601..8847072272 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -23,5 +23,6 @@ export * from "./totals/big-number" export * from "./user" export * from "./api-key" export * from "./link" +export * from "./file" export const MedusaModuleType = Symbol.for("MedusaModule") diff --git a/packages/utils/src/modules-sdk/definition.ts b/packages/utils/src/modules-sdk/definition.ts index 9555cbef00..e1674e9cd8 100644 --- a/packages/utils/src/modules-sdk/definition.ts +++ b/packages/utils/src/modules-sdk/definition.ts @@ -21,4 +21,5 @@ export enum Modules { API_KEY = "apiKey", STORE = "store", CURRENCY = "currency", + FILE = "file", } diff --git a/yarn.lock b/yarn.lock index d9b1716a2b..b5f8b7aca2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8231,6 +8231,18 @@ __metadata: languageName: unknown 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": version: 0.0.0-use.local resolution: "@medusajs/file-local@workspace:packages/medusa-file-local" @@ -32100,6 +32112,7 @@ __metadata: "@swc/jest": ^0.2.36 babel-preset-medusa-package: "*" faker: ^5.5.3 + form-data: ^4.0.0 jest: ^26.6.3 jest-environment-node: 26.6.2 medusa-fulfillment-webshipper: "workspace:*"