diff --git a/packages/medusa/src/api/middlewares/error-handler.ts b/packages/medusa/src/api/middlewares/error-handler.ts index fb3d38897c..37496b09ad 100644 --- a/packages/medusa/src/api/middlewares/error-handler.ts +++ b/packages/medusa/src/api/middlewares/error-handler.ts @@ -11,7 +11,12 @@ const INVALID_REQUEST_ERROR = "invalid_request_error" const INVALID_STATE_ERROR = "invalid_state_error" export default () => { - return (err: MedusaError, req: Request, res: Response, next: NextFunction) => { + return ( + err: MedusaError, + req: Request, + res: Response, + next: NextFunction + ) => { const logger: Logger = req.scope.resolve("logger") logger.error(err) diff --git a/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts b/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts index 15c1b0e848..5dc18c283c 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts @@ -8,7 +8,8 @@ import { } from "class-validator" import PriceListService from "../../../../services/price-list" import { - AdminPriceListPricesCreateReq, CreatePriceListInput, + AdminPriceListPricesCreateReq, + CreatePriceListInput, PriceListStatus, PriceListType, } from "../../../../types/price-list" diff --git a/packages/medusa/src/interfaces/batch-job-strategy.ts b/packages/medusa/src/interfaces/batch-job-strategy.ts new file mode 100644 index 0000000000..e04864c48b --- /dev/null +++ b/packages/medusa/src/interfaces/batch-job-strategy.ts @@ -0,0 +1,56 @@ +import { TransactionBaseService } from "./transaction-base-service" + +export interface IBatchJobStrategy> + extends TransactionBaseService { + /** + * Method for preparing a batch job for processing + */ + prepareBatchJobForProcessing( + batchJobEntity: object, + req: Express.Request + ): Promise + + /** + * Method for pre-processing a batch job + */ + preProcessBatchJob(batchJobId: string): Promise + + /** + * Method does the actual processing of the job. Should report back on the progress of the operation. + */ + processJob(batchJobId: string): Promise + + /** + * Builds and returns a template file that can be downloaded and filled in + */ + buildTemplate() +} + +export abstract class AbstractBatchJobStrategy< + T extends TransactionBaseService + > + extends TransactionBaseService + implements IBatchJobStrategy +{ + static identifier: string + static batchType: string + + async prepareBatchJobForProcessing( + batchJob: object, + req: Express.Request + ): Promise { + return batchJob + } + + public abstract preProcessBatchJob(batchJobId: string): Promise + + public abstract processJob(batchJobId: string): Promise + + public abstract buildTemplate(): Promise +} + +export function isBatchJobStrategy( + object: unknown +): object is IBatchJobStrategy { + return object instanceof AbstractBatchJobStrategy +} diff --git a/packages/medusa/src/interfaces/index.ts b/packages/medusa/src/interfaces/index.ts index cc92e8f118..97fc1ffe72 100644 --- a/packages/medusa/src/interfaces/index.ts +++ b/packages/medusa/src/interfaces/index.ts @@ -2,6 +2,7 @@ export * from "./tax-calculation-strategy" export * from "./cart-completion-strategy" export * from "./tax-service" export * from "./transaction-base-service" +export * from "./batch-job-strategy" export * from "./file-service" export * from "./models/base-entity" export * from "./models/soft-deletable-entity" diff --git a/packages/medusa/src/loaders/__tests__/plugins.spec.ts b/packages/medusa/src/loaders/__tests__/plugins.spec.ts index c9760c39fd..8e71ab04fd 100644 --- a/packages/medusa/src/loaders/__tests__/plugins.spec.ts +++ b/packages/medusa/src/loaders/__tests__/plugins.spec.ts @@ -1,13 +1,19 @@ -import { createContainer, asValue } from "awilix" -import { mkdirSync, rmSync, rmdirSync, writeFileSync } from "fs" +import { + createContainer, + asValue, + Resolver, + ClassOrFunctionReturning, + asFunction, + AwilixContainer, +} from "awilix" +import { mkdirSync, rmSync, writeFileSync } from "fs" import { resolve } from "path" import Logger from "../logger" -import { registerServices } from "../plugins" +import { registerServices, registerStrategies } from "../plugins" import { MedusaContainer } from "../../types/global" -const distTestTargetDirectorPath = resolve(__dirname, "__pluginsLoaderTest__") -const servicesTestTargetDirectoryPath = resolve(distTestTargetDirectorPath, "services") -const buildServiceTemplate = (name: string) => { +// ***** TEMPLATES ***** +const buildServiceTemplate = (name: string): string => { return ` import { BaseService } from "medusa-interfaces" export default class ${name}Service extends BaseService {} @@ -15,13 +21,120 @@ const buildServiceTemplate = (name: string) => { } const buildTransactionBaseServiceServiceTemplate = (name: string) => { return ` - import { TransactionBaseService } from "${resolve(__dirname, "../../interfaces")}" + import { TransactionBaseService } from "${resolve( + __dirname, + "../../interfaces" + )}" export default class ${name}Service extends TransactionBaseService {} ` } -describe('plugins loader', () => { +const buildBatchJobStrategyTemplate = (name: string, type: string): string => { + return ` + import { AbstractBatchJobStrategy } from "../../../../interfaces/batch-job-strategy" + + class ${name}BatchStrategy extends AbstractBatchJobStrategy{ + static identifier = '${name}-identifier'; + static batchType = '${type}'; + + manager_ + transactionManager_ + + validateContext(context) { + throw new Error("Method not implemented.") + } + processJob(batchJobId) { + throw new Error("Method not implemented.") + } + completeJob(batchJobId) { + throw new Error("Method not implemented.") + } + validateFile(fileLocation) { + throw new Error("Method not implemented.") + } + async buildTemplate() { + throw new Error("Method not implemented.") + } + } + + export default ${name}BatchStrategy + ` +} + +const buildPriceSelectionStrategyTemplate = (name: string): string => { + return ` + import { AbstractPriceSelectionStrategy } from "../../../../interfaces/price-selection-strategy" + + class ${name}PriceSelectionStrategy extends AbstractPriceSelectionStrategy { + withTransaction() { + throw new Error("Method not implemented."); + } + calculateVariantPrice(variant_id, context) { + throw new Error("Method not implemented."); + } + } + + export default ${name}PriceSelectionStrategy + ` +} + +const buildTaxCalcStrategyTemplate = (name: string): string => { + return ` + class ${name}TaxCalculationStrategy { + calculate(items, taxLines, calculationContext) { + throw new Error("Method not implemented.") + } + } + + export default ${name}TaxCalculationStrategy + ` +} + +// ***** UTILS ***** + +const distTestTargetDirectorPath = resolve(__dirname, "__pluginsLoaderTest__") + +const getFolderTestTargetDirectoryPath = (folderName: string): string => { + return resolve(distTestTargetDirectorPath, folderName) +} + +function asArray( + resolvers: (ClassOrFunctionReturning | Resolver)[] +): { resolve: (container: AwilixContainer) => unknown[] } { + return { + resolve: (container: AwilixContainer): unknown[] => + resolvers.map((resolver) => container.build(resolver)), + } +} + +// ***** TESTS ***** + +describe("plugins loader", () => { const container = createContainer() as MedusaContainer + container.registerAdd = function ( + this: MedusaContainer, + name: string, + registration: typeof asFunction | typeof asValue + ): MedusaContainer { + const storeKey = name + "_STORE" + + if (this.registrations[storeKey] === undefined) { + this.register(storeKey, asValue([] as Resolver[])) + } + const store = this.resolve(storeKey) as ( + | ClassOrFunctionReturning + | Resolver + )[] + + if (this.registrations[name] === undefined) { + this.register(name, asArray(store)) + } + store.unshift(registration) + + return this + }.bind(container) + + container.register("logger", asValue(Logger)) const pluginsDetails = { resolve: resolve(__dirname, "__pluginsLoaderTest__"), name: `project-plugin`, @@ -29,25 +142,154 @@ describe('plugins loader', () => { options: {}, version: '"fakeVersion', } + let err - describe("registerServices", function() { - beforeAll(() => { - container.register("logger", asValue(Logger)) - mkdirSync(servicesTestTargetDirectoryPath, { mode: "777", recursive: true }) - writeFileSync(resolve(servicesTestTargetDirectoryPath, "test.js"), buildServiceTemplate("test")) - writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.js"), buildServiceTemplate("test2")) - writeFileSync(resolve(servicesTestTargetDirectoryPath, "test3.js"), buildTransactionBaseServiceServiceTemplate("test3")) - writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.js.map"), "map:file") - writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.d.ts"), "export interface Test {}") + afterAll(() => { + rmSync(distTestTargetDirectorPath, { recursive: true, force: true }) + jest.clearAllMocks() + }) + + describe("registerStrategies", function () { + beforeAll(async () => { + mkdirSync(getFolderTestTargetDirectoryPath("strategies"), { + mode: "777", + recursive: true, + }) + writeFileSync( + resolve( + getFolderTestTargetDirectoryPath("strategies"), + "test-batch-1.js" + ), + buildBatchJobStrategyTemplate("testBatch1", "type-1") + ) + writeFileSync( + resolve( + getFolderTestTargetDirectoryPath("strategies"), + "test-price-selection.js" + ), + buildPriceSelectionStrategyTemplate("test") + ) + writeFileSync( + resolve( + getFolderTestTargetDirectoryPath("strategies"), + "test-batch-2.js" + ), + buildBatchJobStrategyTemplate("testBatch2", "type-1") + ) + writeFileSync( + resolve( + getFolderTestTargetDirectoryPath("strategies"), + "test-batch-3.js" + ), + buildBatchJobStrategyTemplate("testBatch3", "type-2") + ) + writeFileSync( + resolve(getFolderTestTargetDirectoryPath("strategies"), "test-tax.js"), + buildTaxCalcStrategyTemplate("test") + ) + + try { + await registerStrategies(pluginsDetails, container) + } catch (e) { + err = e + } }) afterAll(() => { - rmSync(distTestTargetDirectorPath, { recursive: true, force: true }) jest.clearAllMocks() }) - it('should load the services from the services directory but only js files', async () => { - let err; + it("err should be falsy", () => { + expect(err).toBeFalsy() + }) + + it("registers price selection strategy", () => { + const priceSelectionStrategy: (...args: unknown[]) => any = + container.resolve("priceSelectionStrategy") + + expect(priceSelectionStrategy).toBeTruthy() + expect(priceSelectionStrategy.constructor.name).toBe( + "testPriceSelectionStrategy" + ) + }) + + it("registers tax calculation strategy", () => { + const taxCalculationStrategy: (...args: unknown[]) => any = + container.resolve("taxCalculationStrategy") + + expect(taxCalculationStrategy).toBeTruthy() + expect(taxCalculationStrategy.constructor.name).toBe( + "testTaxCalculationStrategy" + ) + }) + + it("registers batch job strategies as single array", () => { + const batchJobStrategies: (...args: unknown[]) => any = + container.resolve("batchJobStrategies") + + expect(batchJobStrategies).toBeTruthy() + expect(Array.isArray(batchJobStrategies)).toBeTruthy() + expect(batchJobStrategies.length).toBe(3) + }) + + it("registers batch job strategies by type and only keep the last", () => { + const batchJobStrategy: (...args: unknown[]) => any = + container.resolve("batchType_type-1") + + expect(batchJobStrategy).toBeTruthy() + expect(batchJobStrategy.constructor.name).toBe("testBatch2BatchStrategy") + expect((batchJobStrategy.constructor as any).batchType).toBe("type-1") + expect((batchJobStrategy.constructor as any).identifier).toBe( + "testBatch2-identifier" + ) + }) + + it("registers batch job strategies by identifier", () => { + const batchJobStrategy: (...args: unknown[]) => any = container.resolve( + "batch_testBatch3-identifier" + ) + + expect(batchJobStrategy).toBeTruthy() + expect(Array.isArray(batchJobStrategy)).toBeFalsy() + expect(batchJobStrategy.constructor.name).toBe("testBatch3BatchStrategy") + }) + }) + + describe("registerServices", function () { + beforeAll(() => { + container.register("logger", asValue(Logger)) + mkdirSync(getFolderTestTargetDirectoryPath("services"), { + mode: "777", + recursive: true, + }) + writeFileSync( + resolve(getFolderTestTargetDirectoryPath("services"), "test.js"), + buildServiceTemplate("test") + ) + writeFileSync( + resolve(getFolderTestTargetDirectoryPath("services"), "test2.js"), + buildServiceTemplate("test2") + ) + writeFileSync( + resolve(getFolderTestTargetDirectoryPath("services"), "test3.js"), + buildTransactionBaseServiceServiceTemplate("test3") + ) + writeFileSync( + resolve(getFolderTestTargetDirectoryPath("services"), "test2.js.map"), + "map:file" + ) + writeFileSync( + resolve(getFolderTestTargetDirectoryPath("services"), "test2.d.ts"), + "export interface Test {}" + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("should load the services from the services directory but only js files", async () => { + let err try { await registerServices(pluginsDetails, container) } catch (e) { @@ -56,9 +298,12 @@ describe('plugins loader', () => { expect(err).toBeFalsy() - const testService: (...args: unknown[]) => any = container.resolve("testService") - const test2Service: (...args: unknown[]) => any = container.resolve("test2Service") - const test3Service: (...args: unknown[]) => any = container.resolve("test3Service") + const testService: (...args: unknown[]) => any = + container.resolve("testService") + const test2Service: (...args: unknown[]) => any = + container.resolve("test2Service") + const test3Service: (...args: unknown[]) => any = + container.resolve("test3Service") expect(testService).toBeTruthy() expect(testService.constructor.name).toBe("testService") @@ -68,4 +313,4 @@ describe('plugins loader', () => { expect(test3Service.constructor.name).toBe("test3Service") }) }) -}) \ No newline at end of file +}) diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index 2ae841bfdb..77a3542bb5 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -30,6 +30,9 @@ import { MedusaContainer, } from "../types/global" import { MiddlewareService } from "../services" +import { isBatchJobStrategy } from "../interfaces/batch-job-strategy" +import { isPriceSelectionStrategy } from "../interfaces/price-selection-strategy" +import logger from "./logger" type Options = { rootDirectory: string @@ -63,7 +66,7 @@ export default async ({ resolved.map(async (pluginDetails) => { registerRepositories(pluginDetails, container) await registerServices(pluginDetails, container) - registerMedusaApi(pluginDetails, container) + await registerMedusaApi(pluginDetails, container) registerApi(pluginDetails, app, rootDirectory, container, activityId) registerCoreRouters(pluginDetails, container) registerSubscribers(pluginDetails, container) @@ -144,42 +147,81 @@ async function runLoaders( ) } -function registerMedusaApi( +async function registerMedusaApi( pluginDetails: PluginDetails, container: MedusaContainer -): void { +): Promise { registerMedusaMiddleware(pluginDetails, container) registerStrategies(pluginDetails, container) } -function registerStrategies( +export function registerStrategies( pluginDetails: PluginDetails, container: MedusaContainer ): void { - let module - try { - const path = `${pluginDetails.resolve}/strategies/tax-calculation` - if (existsSync(path)) { - module = require(path).default - } else { - return - } - } catch (err) { - return - } + const files = glob.sync(`${pluginDetails.resolve}/strategies/[!__]*.js`, {}) + const registeredServices = {} - if (isTaxCalculationStrategy(module.prototype)) { - container.register({ - taxCalculationStrategy: asFunction( - (cradle) => new module(cradle, pluginDetails.options) - ).singleton(), - }) - } else { - const logger = container.resolve("logger") - logger.warn( - `${pluginDetails.resolve}/strategies/tax-calculation did not export a class that implements ITaxCalculationStrategy. Your Medusa server will still work, but if you have written custom tax calculation logic it will not be used. Make sure to implement the ITaxCalculationStrategy interface.` - ) - } + files.map((file) => { + const module = require(file).default + + switch (true) { + case isTaxCalculationStrategy(module.prototype): { + if (!("taxCalculationStrategy" in registeredServices)) { + container.register({ + taxCalculationStrategy: asFunction( + (cradle) => new module(cradle, pluginDetails.options) + ).singleton(), + }) + registeredServices["taxCalculationStrategy"] = file + } else { + logger.warn( + `Cannot register ${file}. A tax calculation strategy is already registered` + ) + } + break + } + + case isBatchJobStrategy(module.prototype): { + container.registerAdd( + "batchJobStrategies", + asFunction((cradle) => new module(cradle, pluginDetails.options)) + ) + + const name = formatRegistrationName(file) + container.register({ + [name]: asFunction( + (cradle) => new module(cradle, pluginDetails.options) + ).singleton(), + [`batch_${module.identifier}`]: aliasTo(name), + [`batchType_${module.batchType}`]: aliasTo(name), + }) + break + } + + case isPriceSelectionStrategy(module.prototype): { + if (!("priceSelectionStrategy" in registeredServices)) { + container.register({ + priceSelectionStrategy: asFunction( + (cradle) => new module(cradle, pluginDetails.options) + ).singleton(), + }) + + registeredServices["priceSelectionStrategy"] = file + } else { + logger.warn( + `Cannot register ${file}. A price selection strategy is already registered` + ) + } + break + } + + default: + logger.warn( + `${file} did not export a class that implements a strategy interface. Your Medusa server will still work, but if you have written custom strategy logic it will not be used. Make sure to implement the proper interface.` + ) + } + }) } function registerMedusaMiddleware( @@ -362,16 +404,10 @@ export async function registerServices( ).singleton(), [`noti_${loaded.identifier}`]: aliasTo(name), }) - } else if (loaded.prototype instanceof FileService) { - // Add the service directly to the container in order to make simple - // resolution if we already know which file storage provider we need to use - container.register({ - [name]: asFunction( - (cradle) => new loaded(cradle, pluginDetails.options) - ), - [`fileService`]: aliasTo(name), - }) - } else if (isFileService(loaded.prototype)) { + } else if ( + loaded.prototype instanceof FileService || + isFileService(loaded.prototype) + ) { // Add the service directly to the container in order to make simple // resolution if we already know which file storage provider we need to use container.register({ diff --git a/packages/medusa/src/loaders/strategies.ts b/packages/medusa/src/loaders/strategies.ts index 3aeef61e59..ba3f6b0b6b 100644 --- a/packages/medusa/src/loaders/strategies.ts +++ b/packages/medusa/src/loaders/strategies.ts @@ -1,11 +1,13 @@ import glob from "glob" import path from "path" -import { AwilixContainer, asFunction } from "awilix" +import { AwilixContainer, asFunction, aliasTo } from "awilix" import formatRegistrationName from "../utils/format-registration-name" +import { isBatchJobStrategy } from "../interfaces" +import { MedusaContainer } from "../types/global" type LoaderOptions = { - container: AwilixContainer + container: MedusaContainer configModule: object isTest?: boolean } @@ -19,19 +21,40 @@ export default ({ container, configModule, isTest }: LoaderOptions): void => { typeof isTest !== "undefined" ? isTest : process.env.NODE_ENV === "test" const corePath = useMock - ? "../strategies/__mocks__/*.js" - : "../strategies/*.js" + ? "../strategies/__mocks__/[!__]*.js" + : "../strategies/**/[!__]*.js" + const coreFull = path.join(__dirname, corePath) - const core = glob.sync(coreFull, { cwd: __dirname }) + const core = glob.sync(coreFull, { + cwd: __dirname, + ignore: ["**/__fixtures__/**", "index.js", "index.ts"], + }) + core.forEach((fn) => { // eslint-disable-next-line @typescript-eslint/no-var-requires const loaded = require(fn).default const name = formatRegistrationName(fn) - container.register({ - [name]: asFunction( - (cradle) => new loaded(cradle, configModule) - ).singleton(), - }) + + if (isBatchJobStrategy(loaded.prototype)) { + container.registerAdd( + "batchJobStrategies", + asFunction((cradle) => new loaded(cradle, configModule)) + ) + + container.register({ + [name]: asFunction( + (cradle) => new loaded(cradle, configModule) + ).singleton(), + [`batch_${loaded.identifier}`]: aliasTo(name), + [`batchType_${loaded.batchType}`]: aliasTo(name), + }) + } else { + container.register({ + [name]: asFunction( + (cradle) => new loaded(cradle, configModule) + ).singleton(), + }) + } }) } diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index 32774ae0c1..371b2df7bd 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -15,7 +15,12 @@ import { CartRepository } from "../repositories/cart" import { LineItemRepository } from "../repositories/line-item" import { PaymentSessionRepository } from "../repositories/payment-session" import { ShippingMethodRepository } from "../repositories/shipping-method" -import { CartCreateProps, CartUpdateProps, FilterableCartProps, LineItemUpdate } from "../types/cart" +import { + CartCreateProps, + CartUpdateProps, + FilterableCartProps, + LineItemUpdate, +} from "../types/cart" import { AddressPayload, FindConfig, TotalField } from "../types/common" import { buildQuery, setMetadata, validateId } from "../utils" import CustomShippingOptionService from "./custom-shipping-option"