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:
Carlos R. L. Rodrigues
2024-02-05 11:59:10 +00:00
committed by GitHub
parent 73fd92a1af
commit 884428a1b5
66 changed files with 1407 additions and 749 deletions
@@ -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",
}
+5
View File
@@ -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)
}
}
}