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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<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
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 = {
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")
})
})
})
})

View File

@@ -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<void> {
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")
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({

View File

@@ -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(),
})
}
})
}

View File

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