feat: event aggregator (#6218)
What: - Event Aggregator Util - Preparation for normalizing event in a new format (backward compatible with the current format) - GQL Schema to joiner config and some Entities configured - Link modules emmiting events
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
export const camelToSnakeCase = (string) =>
|
||||
string.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||
string.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { MessageAggregator } from "../message-aggregator"
|
||||
|
||||
describe("MessageAggregator", function () {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks
|
||||
})
|
||||
|
||||
it("should group messages by any given group of keys", function () {
|
||||
const aggregator = new MessageAggregator()
|
||||
aggregator.save({
|
||||
eventName: "ProductVariant.created",
|
||||
body: {
|
||||
metadata: {
|
||||
service: "ProductService",
|
||||
action: "created",
|
||||
object: "ProductVariant",
|
||||
eventGroupId: "1",
|
||||
},
|
||||
data: { id: 999 },
|
||||
},
|
||||
})
|
||||
aggregator.save({
|
||||
eventName: "Product.created",
|
||||
body: {
|
||||
metadata: {
|
||||
service: "ProductService",
|
||||
action: "created",
|
||||
object: "Product",
|
||||
eventGroupId: "1",
|
||||
},
|
||||
data: { id: 1 },
|
||||
},
|
||||
})
|
||||
aggregator.save({
|
||||
eventName: "ProductVariant.created",
|
||||
body: {
|
||||
metadata: {
|
||||
service: "ProductService",
|
||||
action: "created",
|
||||
object: "ProductVariant",
|
||||
eventGroupId: "1",
|
||||
},
|
||||
data: { id: 222 },
|
||||
},
|
||||
})
|
||||
aggregator.save({
|
||||
eventName: "ProductType.detached",
|
||||
body: {
|
||||
metadata: {
|
||||
service: "ProductService",
|
||||
action: "detached",
|
||||
object: "ProductType",
|
||||
eventGroupId: "1",
|
||||
},
|
||||
data: { id: 333 },
|
||||
},
|
||||
})
|
||||
aggregator.save({
|
||||
eventName: "ProductVariant.updated",
|
||||
body: {
|
||||
metadata: {
|
||||
service: "ProductService",
|
||||
action: "updated",
|
||||
object: "ProductVariant",
|
||||
eventGroupId: "1",
|
||||
},
|
||||
data: { id: 123 },
|
||||
},
|
||||
})
|
||||
|
||||
const format = {
|
||||
groupBy: ["eventName", "body.metadata.object", "body.metadata.action"],
|
||||
sortBy: {
|
||||
"body.metadata.object": ["ProductType", "ProductVariant", "Product"],
|
||||
"body.data.id": "asc",
|
||||
},
|
||||
}
|
||||
|
||||
const messages = aggregator.getMessages(format)
|
||||
|
||||
expect(Object.keys(messages)).toHaveLength(4)
|
||||
|
||||
const allGroups = Object.values(messages)
|
||||
|
||||
expect(allGroups[0]).toEqual([
|
||||
{
|
||||
eventName: "ProductType.detached",
|
||||
body: {
|
||||
metadata: {
|
||||
service: "ProductService",
|
||||
action: "detached",
|
||||
object: "ProductType",
|
||||
eventGroupId: "1",
|
||||
},
|
||||
data: { id: 333 },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(allGroups[1]).toEqual([
|
||||
{
|
||||
eventName: "ProductVariant.updated",
|
||||
body: {
|
||||
metadata: {
|
||||
service: "ProductService",
|
||||
action: "updated",
|
||||
object: "ProductVariant",
|
||||
eventGroupId: "1",
|
||||
},
|
||||
data: { id: 123 },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(allGroups[2]).toEqual([
|
||||
{
|
||||
eventName: "ProductVariant.created",
|
||||
body: {
|
||||
metadata: {
|
||||
service: "ProductService",
|
||||
action: "created",
|
||||
object: "ProductVariant",
|
||||
eventGroupId: "1",
|
||||
},
|
||||
data: { id: 222 },
|
||||
},
|
||||
},
|
||||
{
|
||||
eventName: "ProductVariant.created",
|
||||
body: {
|
||||
metadata: {
|
||||
service: "ProductService",
|
||||
action: "created",
|
||||
object: "ProductVariant",
|
||||
eventGroupId: "1",
|
||||
},
|
||||
data: { id: 999 },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(allGroups[3]).toEqual([
|
||||
{
|
||||
eventName: "Product.created",
|
||||
body: {
|
||||
metadata: {
|
||||
service: "ProductService",
|
||||
action: "created",
|
||||
object: "Product",
|
||||
eventGroupId: "1",
|
||||
},
|
||||
data: { id: 1 },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Context, EventBusTypes } from "@medusajs/types"
|
||||
import { CommonEvents } from "./common-events"
|
||||
|
||||
/**
|
||||
* Build messages from message data to be consumed by the event bus and emitted to the consumer
|
||||
* @param MessageFormat
|
||||
* @param options
|
||||
*/
|
||||
export function buildEventMessages<T>(
|
||||
messageData:
|
||||
| EventBusTypes.MessageFormat<T>
|
||||
| EventBusTypes.MessageFormat<T>[],
|
||||
options?: Record<string, unknown>
|
||||
): EventBusTypes.Message<T>[] {
|
||||
const messageData_ = Array.isArray(messageData) ? messageData : [messageData]
|
||||
const messages: EventBusTypes.Message<T>[] = []
|
||||
|
||||
messageData_.map((data) => {
|
||||
const data_ = Array.isArray(data.data) ? data.data : [data.data]
|
||||
data_.forEach((bodyData) => {
|
||||
const message = {
|
||||
eventName: data.eventName,
|
||||
body: {
|
||||
metadata: data.metadata,
|
||||
data: bodyData,
|
||||
},
|
||||
options,
|
||||
}
|
||||
messages.push(message)
|
||||
})
|
||||
})
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to compose and normalize a Message to be emitted by EventBus Module
|
||||
* @param eventName Name of the event to be emitted
|
||||
* @param data The content of the message
|
||||
* @param metadata Metadata of the message
|
||||
* @param context Context from the caller service
|
||||
* @param options Options to be passed to the event bus
|
||||
*/
|
||||
export function composeMessage(
|
||||
eventName: string,
|
||||
{
|
||||
data,
|
||||
service,
|
||||
entity,
|
||||
action,
|
||||
context = {},
|
||||
options,
|
||||
}: {
|
||||
data: unknown
|
||||
service: string
|
||||
entity: string
|
||||
action?: string
|
||||
context?: Context
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
): EventBusTypes.Message {
|
||||
const act = action || eventName.split(".").pop()
|
||||
if (!action && !Object.values(CommonEvents).includes(act as CommonEvents)) {
|
||||
throw new Error("Action is required if eventName is not a CommonEvent")
|
||||
}
|
||||
|
||||
const metadata: EventBusTypes.MessageBody["metadata"] = {
|
||||
service,
|
||||
object: entity,
|
||||
action: act!,
|
||||
}
|
||||
|
||||
if (context.eventGroupId) {
|
||||
metadata.eventGroupId = context.eventGroupId
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
body: {
|
||||
metadata,
|
||||
data,
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum CommonEvents {
|
||||
CREATED = "created",
|
||||
UPDATED = "updated",
|
||||
DELETED = "deleted",
|
||||
ATTACHED = "attached",
|
||||
DETACHED = "detached",
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export abstract class AbstractEventBusModuleService
|
||||
options: Record<string, unknown>
|
||||
): Promise<void>
|
||||
abstract emit<T>(data: EventBusTypes.EmitData<T>[]): Promise<void>
|
||||
abstract emit<T>(data: EventBusTypes.Message<T>[]): Promise<void>
|
||||
|
||||
protected storeSubscribers({
|
||||
event,
|
||||
@@ -101,3 +102,7 @@ export abstract class AbstractEventBusModuleService
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./build-event-messages"
|
||||
export * from "./common-events"
|
||||
export * from "./message-aggregator"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
IMessageAggregator,
|
||||
Message,
|
||||
MessageAggregatorFormat,
|
||||
} from "@medusajs/types"
|
||||
|
||||
export class MessageAggregator implements IMessageAggregator {
|
||||
private messages: Message[]
|
||||
|
||||
constructor() {
|
||||
this.messages = []
|
||||
}
|
||||
|
||||
save(msg: Message | Message[]): void {
|
||||
if (!msg || (Array.isArray(msg) && msg.length === 0)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(msg)) {
|
||||
this.messages.push(...msg)
|
||||
} else {
|
||||
this.messages.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
getMessages(format?: MessageAggregatorFormat): {
|
||||
[group: string]: Message[]
|
||||
} {
|
||||
const { groupBy, sortBy } = format ?? {}
|
||||
|
||||
if (sortBy) {
|
||||
this.messages.sort((a, b) => this.compareMessages(a, b, sortBy))
|
||||
}
|
||||
|
||||
let messages: { [group: string]: Message[] } = { default: this.messages }
|
||||
|
||||
if (groupBy) {
|
||||
const groupedMessages = this.messages.reduce<{
|
||||
[key: string]: Message[]
|
||||
}>((acc, msg) => {
|
||||
const key = groupBy
|
||||
.map((field) => this.getValueFromPath(msg, field))
|
||||
.join("-")
|
||||
if (!acc[key]) {
|
||||
acc[key] = []
|
||||
}
|
||||
acc[key].push(msg)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
messages = groupedMessages
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
clearMessages(): void {
|
||||
this.messages = []
|
||||
}
|
||||
|
||||
private getValueFromPath(obj: any, path: string): any {
|
||||
const keys = path.split(".")
|
||||
for (const key of keys) {
|
||||
obj = obj[key]
|
||||
if (obj === undefined) break
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
private compareMessages(
|
||||
a: Message,
|
||||
b: Message,
|
||||
sortBy: MessageAggregatorFormat["sortBy"]
|
||||
): number {
|
||||
for (const key of Object.keys(sortBy!)) {
|
||||
const orderCriteria = sortBy![key]
|
||||
const valueA = this.getValueFromPath(a, key)
|
||||
const valueB = this.getValueFromPath(b, key)
|
||||
|
||||
// User defined order
|
||||
if (Array.isArray(orderCriteria)) {
|
||||
const indexA = orderCriteria.indexOf(valueA)
|
||||
const indexB = orderCriteria.indexOf(valueB)
|
||||
|
||||
if (indexA === indexB) {
|
||||
continue
|
||||
} else if (indexA === -1) {
|
||||
return 1
|
||||
} else if (indexB === -1) {
|
||||
return -1
|
||||
} else {
|
||||
return indexA - indexB
|
||||
}
|
||||
} else {
|
||||
// Ascending or descending order
|
||||
let orderMultiplier = 1
|
||||
if (orderCriteria === "desc" || orderCriteria === -1) {
|
||||
orderMultiplier = -1
|
||||
}
|
||||
|
||||
if (valueA === valueB) {
|
||||
continue
|
||||
} else if (valueA < valueB) {
|
||||
return -1 * orderMultiplier
|
||||
} else {
|
||||
return 1 * orderMultiplier
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function InjectIntoContext(
|
||||
properties: Record<string, unknown | Function>
|
||||
): MethodDecorator {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: any
|
||||
): void {
|
||||
if (!target.MedusaContextIndex_) {
|
||||
throw new Error(
|
||||
`To apply @InjectIntoContext you have to flag a parameter using @MedusaContext`
|
||||
)
|
||||
}
|
||||
|
||||
const argIndex = target.MedusaContextIndex_[propertyKey]
|
||||
const original = descriptor.value
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
for (const key of Object.keys(properties)) {
|
||||
args[argIndex] = args[argIndex] ?? {}
|
||||
args[argIndex][key] =
|
||||
args[argIndex][key] ??
|
||||
(typeof properties[key] === "function"
|
||||
? (properties[key] as Function).apply(this, args)
|
||||
: properties[key])
|
||||
}
|
||||
|
||||
return await original.apply(this, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user