feat(medusa): BatchJobStrategy and loaders (#1434)

* add batch job strategy interface

* update plugin loaders

* remove comment

* make map async

* ensure that only one of each strategy is registered

* register strategies plural

* add identifier and batchType properties

* extend batch job strategy identification method

* initial test

* update loaders to accomodate different ways of accessing batch job strategies

* identifier batch type field

* redo merge in plugins

* update interface and load only js files

* use switches instead of elif

* remove comments

* use static properties for strategy registration

* update tests

* fix unit tests

* update test names

* update isBatchJobStrategy method

* add check for TransactionBaseService in services for plugins

* update interfaces export

* update batchjob strategy interface with a prepare script

* update loaders

* update batchjob strategy interface

* remove everything but public interface methods from batchJobStrategy

* add default implementation to prepareBathJobForProcessing

* remove unused import
This commit is contained in:
Philip Korsholm
2022-06-15 10:46:55 +02:00
committed by GitHub
parent cffb03d197
commit 886dcbc82f
8 changed files with 446 additions and 74 deletions
@@ -11,7 +11,12 @@ const INVALID_REQUEST_ERROR = "invalid_request_error"
const INVALID_STATE_ERROR = "invalid_state_error" const INVALID_STATE_ERROR = "invalid_state_error"
export default () => { 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") const logger: Logger = req.scope.resolve("logger")
logger.error(err) logger.error(err)
@@ -8,7 +8,8 @@ import {
} from "class-validator" } from "class-validator"
import PriceListService from "../../../../services/price-list" import PriceListService from "../../../../services/price-list"
import { import {
AdminPriceListPricesCreateReq, CreatePriceListInput, AdminPriceListPricesCreateReq,
CreatePriceListInput,
PriceListStatus, PriceListStatus,
PriceListType, PriceListType,
} from "../../../../types/price-list" } from "../../../../types/price-list"
@@ -0,0 +1,56 @@
import { TransactionBaseService } from "./transaction-base-service"
export interface IBatchJobStrategy<T extends TransactionBaseService<any>>
extends TransactionBaseService<T> {
/**
* Method for preparing a batch job for processing
*/
prepareBatchJobForProcessing(
batchJobEntity: object,
req: Express.Request
): Promise<object>
/**
* Method for pre-processing a batch job
*/
preProcessBatchJob(batchJobId: string): Promise<void>
/**
* Method does the actual processing of the job. Should report back on the progress of the operation.
*/
processJob(batchJobId: string): Promise<void>
/**
* Builds and returns a template file that can be downloaded and filled in
*/
buildTemplate()
}
export abstract class AbstractBatchJobStrategy<
T extends TransactionBaseService<any>
>
extends TransactionBaseService<T>
implements IBatchJobStrategy<T>
{
static identifier: string
static batchType: string
async prepareBatchJobForProcessing(
batchJob: object,
req: Express.Request
): Promise<object> {
return batchJob
}
public abstract preProcessBatchJob(batchJobId: string): Promise<void>
public abstract processJob(batchJobId: string): Promise<void>
public abstract buildTemplate(): Promise<string>
}
export function isBatchJobStrategy(
object: unknown
): object is IBatchJobStrategy<any> {
return object instanceof AbstractBatchJobStrategy
}
+1
View File
@@ -2,6 +2,7 @@ export * from "./tax-calculation-strategy"
export * from "./cart-completion-strategy" export * from "./cart-completion-strategy"
export * from "./tax-service" export * from "./tax-service"
export * from "./transaction-base-service" export * from "./transaction-base-service"
export * from "./batch-job-strategy"
export * from "./file-service" export * from "./file-service"
export * from "./models/base-entity" export * from "./models/base-entity"
export * from "./models/soft-deletable-entity" export * from "./models/soft-deletable-entity"
@@ -1,13 +1,19 @@
import { createContainer, asValue } from "awilix" import {
import { mkdirSync, rmSync, rmdirSync, writeFileSync } from "fs" createContainer,
asValue,
Resolver,
ClassOrFunctionReturning,
asFunction,
AwilixContainer,
} from "awilix"
import { mkdirSync, rmSync, writeFileSync } from "fs"
import { resolve } from "path" import { resolve } from "path"
import Logger from "../logger" import Logger from "../logger"
import { registerServices } from "../plugins" import { registerServices, registerStrategies } from "../plugins"
import { MedusaContainer } from "../../types/global" import { MedusaContainer } from "../../types/global"
const distTestTargetDirectorPath = resolve(__dirname, "__pluginsLoaderTest__") // ***** TEMPLATES *****
const servicesTestTargetDirectoryPath = resolve(distTestTargetDirectorPath, "services") const buildServiceTemplate = (name: string): string => {
const buildServiceTemplate = (name: string) => {
return ` return `
import { BaseService } from "medusa-interfaces" import { BaseService } from "medusa-interfaces"
export default class ${name}Service extends BaseService {} export default class ${name}Service extends BaseService {}
@@ -15,13 +21,120 @@ const buildServiceTemplate = (name: string) => {
} }
const buildTransactionBaseServiceServiceTemplate = (name: string) => { const buildTransactionBaseServiceServiceTemplate = (name: string) => {
return ` return `
import { TransactionBaseService } from "${resolve(__dirname, "../../interfaces")}" import { TransactionBaseService } from "${resolve(
__dirname,
"../../interfaces"
)}"
export default class ${name}Service extends TransactionBaseService {} 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<unknown> | Resolver<unknown>)[]
): { resolve: (container: AwilixContainer) => unknown[] } {
return {
resolve: (container: AwilixContainer): unknown[] =>
resolvers.map((resolver) => container.build(resolver)),
}
}
// ***** TESTS *****
describe("plugins loader", () => {
const container = createContainer() as MedusaContainer 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<unknown>[]))
}
const store = this.resolve(storeKey) as (
| ClassOrFunctionReturning<unknown>
| Resolver<unknown>
)[]
if (this.registrations[name] === undefined) {
this.register(name, asArray(store))
}
store.unshift(registration)
return this
}.bind(container)
container.register("logger", asValue(Logger))
const pluginsDetails = { const pluginsDetails = {
resolve: resolve(__dirname, "__pluginsLoaderTest__"), resolve: resolve(__dirname, "__pluginsLoaderTest__"),
name: `project-plugin`, name: `project-plugin`,
@@ -29,25 +142,154 @@ describe('plugins loader', () => {
options: {}, options: {},
version: '"fakeVersion', version: '"fakeVersion',
} }
let err
describe("registerServices", function() { afterAll(() => {
beforeAll(() => { rmSync(distTestTargetDirectorPath, { recursive: true, force: true })
container.register("logger", asValue(Logger)) jest.clearAllMocks()
mkdirSync(servicesTestTargetDirectoryPath, { mode: "777", recursive: true }) })
writeFileSync(resolve(servicesTestTargetDirectoryPath, "test.js"), buildServiceTemplate("test"))
writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.js"), buildServiceTemplate("test2")) describe("registerStrategies", function () {
writeFileSync(resolve(servicesTestTargetDirectoryPath, "test3.js"), buildTransactionBaseServiceServiceTemplate("test3")) beforeAll(async () => {
writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.js.map"), "map:file") mkdirSync(getFolderTestTargetDirectoryPath("strategies"), {
writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.d.ts"), "export interface Test {}") 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(() => { afterAll(() => {
rmSync(distTestTargetDirectorPath, { recursive: true, force: true })
jest.clearAllMocks() jest.clearAllMocks()
}) })
it('should load the services from the services directory but only js files', async () => { it("err should be falsy", () => {
let err; 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 { try {
await registerServices(pluginsDetails, container) await registerServices(pluginsDetails, container)
} catch (e) { } catch (e) {
@@ -56,9 +298,12 @@ describe('plugins loader', () => {
expect(err).toBeFalsy() expect(err).toBeFalsy()
const testService: (...args: unknown[]) => any = container.resolve("testService") const testService: (...args: unknown[]) => any =
const test2Service: (...args: unknown[]) => any = container.resolve("test2Service") container.resolve("testService")
const test3Service: (...args: unknown[]) => any = container.resolve("test3Service") const test2Service: (...args: unknown[]) => any =
container.resolve("test2Service")
const test3Service: (...args: unknown[]) => any =
container.resolve("test3Service")
expect(testService).toBeTruthy() expect(testService).toBeTruthy()
expect(testService.constructor.name).toBe("testService") expect(testService.constructor.name).toBe("testService")
@@ -68,4 +313,4 @@ describe('plugins loader', () => {
expect(test3Service.constructor.name).toBe("test3Service") expect(test3Service.constructor.name).toBe("test3Service")
}) })
}) })
}) })
+73 -37
View File
@@ -30,6 +30,9 @@ import {
MedusaContainer, MedusaContainer,
} from "../types/global" } from "../types/global"
import { MiddlewareService } from "../services" import { MiddlewareService } from "../services"
import { isBatchJobStrategy } from "../interfaces/batch-job-strategy"
import { isPriceSelectionStrategy } from "../interfaces/price-selection-strategy"
import logger from "./logger"
type Options = { type Options = {
rootDirectory: string rootDirectory: string
@@ -63,7 +66,7 @@ export default async ({
resolved.map(async (pluginDetails) => { resolved.map(async (pluginDetails) => {
registerRepositories(pluginDetails, container) registerRepositories(pluginDetails, container)
await registerServices(pluginDetails, container) await registerServices(pluginDetails, container)
registerMedusaApi(pluginDetails, container) await registerMedusaApi(pluginDetails, container)
registerApi(pluginDetails, app, rootDirectory, container, activityId) registerApi(pluginDetails, app, rootDirectory, container, activityId)
registerCoreRouters(pluginDetails, container) registerCoreRouters(pluginDetails, container)
registerSubscribers(pluginDetails, container) registerSubscribers(pluginDetails, container)
@@ -144,42 +147,81 @@ async function runLoaders(
) )
} }
function registerMedusaApi( async function registerMedusaApi(
pluginDetails: PluginDetails, pluginDetails: PluginDetails,
container: MedusaContainer container: MedusaContainer
): void { ): Promise<void> {
registerMedusaMiddleware(pluginDetails, container) registerMedusaMiddleware(pluginDetails, container)
registerStrategies(pluginDetails, container) registerStrategies(pluginDetails, container)
} }
function registerStrategies( export function registerStrategies(
pluginDetails: PluginDetails, pluginDetails: PluginDetails,
container: MedusaContainer container: MedusaContainer
): void { ): void {
let module const files = glob.sync(`${pluginDetails.resolve}/strategies/[!__]*.js`, {})
try { const registeredServices = {}
const path = `${pluginDetails.resolve}/strategies/tax-calculation`
if (existsSync(path)) {
module = require(path).default
} else {
return
}
} catch (err) {
return
}
if (isTaxCalculationStrategy(module.prototype)) { files.map((file) => {
container.register({ const module = require(file).default
taxCalculationStrategy: asFunction(
(cradle) => new module(cradle, pluginDetails.options) switch (true) {
).singleton(), case isTaxCalculationStrategy(module.prototype): {
}) if (!("taxCalculationStrategy" in registeredServices)) {
} else { container.register({
const logger = container.resolve<Logger>("logger") taxCalculationStrategy: asFunction(
logger.warn( (cradle) => new module(cradle, pluginDetails.options)
`${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.` ).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( function registerMedusaMiddleware(
@@ -362,16 +404,10 @@ export async function registerServices(
).singleton(), ).singleton(),
[`noti_${loaded.identifier}`]: aliasTo(name), [`noti_${loaded.identifier}`]: aliasTo(name),
}) })
} else if (loaded.prototype instanceof FileService) { } else if (
// Add the service directly to the container in order to make simple loaded.prototype instanceof FileService ||
// resolution if we already know which file storage provider we need to use isFileService(loaded.prototype)
container.register({ ) {
[name]: asFunction(
(cradle) => new loaded(cradle, pluginDetails.options)
),
[`fileService`]: aliasTo(name),
})
} else if (isFileService(loaded.prototype)) {
// Add the service directly to the container in order to make simple // 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 // resolution if we already know which file storage provider we need to use
container.register({ container.register({
+33 -10
View File
@@ -1,11 +1,13 @@
import glob from "glob" import glob from "glob"
import path from "path" import path from "path"
import { AwilixContainer, asFunction } from "awilix" import { AwilixContainer, asFunction, aliasTo } from "awilix"
import formatRegistrationName from "../utils/format-registration-name" import formatRegistrationName from "../utils/format-registration-name"
import { isBatchJobStrategy } from "../interfaces"
import { MedusaContainer } from "../types/global"
type LoaderOptions = { type LoaderOptions = {
container: AwilixContainer container: MedusaContainer
configModule: object configModule: object
isTest?: boolean isTest?: boolean
} }
@@ -19,19 +21,40 @@ export default ({ container, configModule, isTest }: LoaderOptions): void => {
typeof isTest !== "undefined" ? isTest : process.env.NODE_ENV === "test" typeof isTest !== "undefined" ? isTest : process.env.NODE_ENV === "test"
const corePath = useMock const corePath = useMock
? "../strategies/__mocks__/*.js" ? "../strategies/__mocks__/[!__]*.js"
: "../strategies/*.js" : "../strategies/**/[!__]*.js"
const coreFull = path.join(__dirname, corePath) 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) => { core.forEach((fn) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const loaded = require(fn).default const loaded = require(fn).default
const name = formatRegistrationName(fn) const name = formatRegistrationName(fn)
container.register({
[name]: asFunction( if (isBatchJobStrategy(loaded.prototype)) {
(cradle) => new loaded(cradle, configModule) container.registerAdd(
).singleton(), "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(),
})
}
}) })
} }
+6 -1
View File
@@ -15,7 +15,12 @@ import { CartRepository } from "../repositories/cart"
import { LineItemRepository } from "../repositories/line-item" import { LineItemRepository } from "../repositories/line-item"
import { PaymentSessionRepository } from "../repositories/payment-session" import { PaymentSessionRepository } from "../repositories/payment-session"
import { ShippingMethodRepository } from "../repositories/shipping-method" 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 { AddressPayload, FindConfig, TotalField } from "../types/common"
import { buildQuery, setMetadata, validateId } from "../utils" import { buildQuery, setMetadata, validateId } from "../utils"
import CustomShippingOptionService from "./custom-shipping-option" import CustomShippingOptionService from "./custom-shipping-option"