-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add support for firealerts events in Eventarc emulator. #7355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8eff3d8
62b94e7
7b7d22f
1194f23
5f33271
046a959
35128d9
702002d
7c8e9bd
9b349a6
fabd95f
35b1eb3
b619e16
36a2ebe
b378930
9131cac
57efc86
736417b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,8 +9,9 @@ import { CloudEvent } from "./events/types"; | |
| import { EmulatorRegistry } from "./registry"; | ||
| import { FirebaseError } from "../error"; | ||
| import { cloudEventFromProtoToJson } from "./eventarcEmulatorUtils"; | ||
| import * as cors from "cors"; | ||
|
|
||
| interface CustomEventTrigger { | ||
| interface EmulatedEventTrigger { | ||
| projectId: string; | ||
| triggerName: string; | ||
| eventTrigger: EventTrigger; | ||
|
|
@@ -24,25 +25,42 @@ export interface EventarcEmulatorArgs { | |
| port?: number; | ||
| host?: string; | ||
| } | ||
| const GOOGLE_CHANNEL = "google"; | ||
|
|
||
| export class EventarcEmulator implements EmulatorInstance { | ||
| private destroyServer?: () => Promise<void>; | ||
|
|
||
| private logger = EmulatorLogger.forEmulator(Emulators.EVENTARC); | ||
| private customEvents: { [key: string]: CustomEventTrigger[] } = {}; | ||
| private events: { [key: string]: EmulatedEventTrigger[] } = {}; | ||
|
|
||
| constructor(private args: EventarcEmulatorArgs) {} | ||
|
|
||
| createHubServer(): express.Application { | ||
| const registerTriggerRoute = `/emulator/v1/projects/:project_id/triggers/:trigger_name(*)`; | ||
| const registerTriggerHandler: express.RequestHandler = (req, res) => { | ||
| try { | ||
| const { projectId, triggerName, eventTrigger, key } = getTriggerIdentifiers(req); | ||
| this.logger.logLabeled( | ||
| "BULLET", | ||
| "eventarc", | ||
| `Registering Eventarc event trigger for ${key} with trigger name ${triggerName}.`, | ||
| ); | ||
| const eventTriggers = this.events[key] || []; | ||
| eventTriggers.push({ projectId, triggerName, eventTrigger }); | ||
| this.events[key] = eventTriggers; | ||
| res.status(200).send({ res: "OK" }); | ||
| } catch (error) { | ||
| res.status(400).send({ error }); | ||
| } | ||
| }; | ||
|
|
||
| const getTriggerIdentifiers = (req: express.Request) => { | ||
| const projectId = req.params.project_id; | ||
| const triggerName = req.params.trigger_name; | ||
| if (!projectId || !triggerName) { | ||
| const error = "Missing project ID or trigger name."; | ||
| this.logger.log("ERROR", error); | ||
| res.status(400).send({ error }); | ||
| return; | ||
| throw error; | ||
| } | ||
| const bodyString = (req as RequestWithRawBody).rawBody.toString(); | ||
| const substituted = bodyString.replaceAll("${PROJECT_ID}", projectId); | ||
|
|
@@ -51,24 +69,58 @@ export class EventarcEmulator implements EmulatorInstance { | |
| if (!eventTrigger) { | ||
| const error = `Missing event trigger for ${triggerName}.`; | ||
| this.logger.log("ERROR", error); | ||
| throw error; | ||
| } | ||
| const channel = eventTrigger.channel || GOOGLE_CHANNEL; | ||
| const key = `${eventTrigger.eventType}-${channel}`; | ||
| return { projectId, triggerName, eventTrigger, key }; | ||
| }; | ||
|
|
||
| const removeTriggerRoute = `/emulator/v1/remove/projects/:project_id/triggers/:trigger_name`; | ||
| const removeTriggerHandler: express.RequestHandler = (req, res) => { | ||
| try { | ||
| const { projectId, triggerName, eventTrigger, key } = getTriggerIdentifiers(req); | ||
| this.logger.logLabeled( | ||
| "BULLET", | ||
| "eventarc", | ||
| `Removing Eventarc event trigger for ${key} with trigger name ${triggerName}.`, | ||
| ); | ||
| const eventTriggers = this.events[key] || []; | ||
| const triggerIdentifier = { projectId, triggerName, eventTrigger }; | ||
| const removeIdx = eventTriggers.findIndex( | ||
| (e) => JSON.stringify(triggerIdentifier) === JSON.stringify(e), | ||
| ); | ||
| if (removeIdx === -1) { | ||
| this.logger.logLabeled("ERROR", "eventarc", "Tried to remove nonexistent trigger"); | ||
| throw new Error(`Unable to delete function trigger ${triggerName}`); | ||
| } | ||
| eventTriggers.splice(removeIdx, 1); | ||
| if (eventTriggers.length === 0) { | ||
| delete this.events[key]; | ||
| } else { | ||
| this.events[key] = eventTriggers; | ||
| } | ||
| res.status(200).send({ res: "OK" }); | ||
| } catch (error) { | ||
| res.status(400).send({ error }); | ||
| return; | ||
| } | ||
| const key = `${eventTrigger.eventType}-${eventTrigger.channel}`; | ||
| this.logger.logLabeled( | ||
| "BULLET", | ||
| "eventarc", | ||
| `Registering custom event trigger for ${key} with trigger name ${triggerName}.`, | ||
| ); | ||
| const customEventTriggers = this.customEvents[key] || []; | ||
| customEventTriggers.push({ projectId, triggerName, eventTrigger }); | ||
| this.customEvents[key] = customEventTriggers; | ||
| res.status(200).send({ res: "OK" }); | ||
| }; | ||
|
|
||
| const getTriggersRoute = `/google/getTriggers`; | ||
| const getTriggersHandler: express.RequestHandler = (req, res) => { | ||
| res.status(200).send(this.events); | ||
| }; | ||
|
|
||
| const publishEventsRoute = `/projects/:project_id/locations/:location/channels/:channel::publishEvents`; | ||
| const publishNativeEventsRoute = `/google/publishEvents`; | ||
|
|
||
| const publishEventsHandler: express.RequestHandler = (req, res) => { | ||
| const channel = `projects/${req.params.project_id}/locations/${req.params.location}/channels/${req.params.channel}`; | ||
| const isCustom = req.params.project_id && req.params.channel; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
|
|
||
| const channel = isCustom | ||
| ? `projects/${req.params.project_id}/locations/${req.params.location}/channels/${req.params.channel}` | ||
| : GOOGLE_CHANNEL; | ||
|
|
||
| const body = JSON.parse((req as RequestWithRawBody).rawBody.toString()); | ||
| for (const event of body.events) { | ||
| if (!event.type) { | ||
|
|
@@ -77,9 +129,9 @@ export class EventarcEmulator implements EmulatorInstance { | |
| } | ||
| this.logger.log( | ||
| "INFO", | ||
| `Received custom event at channel ${channel}: ${JSON.stringify(event, null, 2)}`, | ||
| `Received event at channel ${channel}: ${JSON.stringify(event, null, 2)}`, | ||
| ); | ||
| this.triggerCustomEventFunction(channel, event); | ||
| this.triggerEventFunction(channel, event); | ||
| } | ||
| res.sendStatus(200); | ||
| }; | ||
|
|
@@ -98,52 +150,63 @@ export class EventarcEmulator implements EmulatorInstance { | |
| const hub = express(); | ||
| hub.post([registerTriggerRoute], dataMiddleware, registerTriggerHandler); | ||
| hub.post([publishEventsRoute], dataMiddleware, publishEventsHandler); | ||
| hub.post( | ||
| [publishNativeEventsRoute], | ||
| dataMiddleware, | ||
| cors({ origin: true }), | ||
| publishEventsHandler, | ||
| ); | ||
| hub.post([removeTriggerRoute], dataMiddleware, removeTriggerHandler); | ||
| hub.get([getTriggersRoute], cors({ origin: true }), getTriggersHandler); | ||
| hub.all("*", (req, res) => { | ||
| this.logger.log("DEBUG", `Eventarc emulator received unknown request at path ${req.path}`); | ||
| res.sendStatus(404); | ||
| }); | ||
| return hub; | ||
| } | ||
|
|
||
| async triggerCustomEventFunction(channel: string, event: CloudEvent<any>) { | ||
| async triggerEventFunction(channel: string, event: CloudEvent<any>): Promise<void[]> { | ||
| if (!EmulatorRegistry.isRunning(Emulators.FUNCTIONS)) { | ||
| this.logger.log("INFO", "Functions emulator not found. This should not happen."); | ||
| return Promise.reject(); | ||
| } | ||
| const key = `${event.type}-${channel}`; | ||
| const triggers = this.customEvents[key] || []; | ||
| const triggers = this.events[key] || []; | ||
| const eventPayload = channel === GOOGLE_CHANNEL ? event : cloudEventFromProtoToJson(event); | ||
| return await Promise.all( | ||
| triggers | ||
| .filter( | ||
| (trigger) => | ||
| !trigger.eventTrigger.eventFilters || | ||
| this.matchesAll(event, trigger.eventTrigger.eventFilters), | ||
| ) | ||
| .map((trigger) => | ||
| EmulatorRegistry.client(Emulators.FUNCTIONS) | ||
| .request<CloudEvent<any>, NodeJS.ReadableStream>({ | ||
| method: "POST", | ||
| path: `/functions/projects/${trigger.projectId}/triggers/${trigger.triggerName}`, | ||
| body: JSON.stringify(cloudEventFromProtoToJson(event)), | ||
| responseType: "stream", | ||
| resolveOnHTTPError: true, | ||
| }) | ||
| .then((res) => { | ||
| // Since the response type is a stream and using `resolveOnHTTPError: true`, we check status manually. | ||
| if (res.status >= 400) { | ||
| throw new FirebaseError(`Received non-200 status code: ${res.status}`); | ||
| } | ||
| }) | ||
| .catch((err) => { | ||
| this.logger.log( | ||
| "ERROR", | ||
| `Failed to trigger Functions emulator for ${trigger.triggerName}: ${err}`, | ||
| ); | ||
| }), | ||
| ), | ||
| .map((trigger) => this.callFunctionTrigger(trigger, eventPayload)), | ||
| ); | ||
| } | ||
|
|
||
| callFunctionTrigger(trigger: EmulatedEventTrigger, event: CloudEvent<any>): Promise<void> { | ||
| return EmulatorRegistry.client(Emulators.FUNCTIONS) | ||
| .request<CloudEvent<any>, NodeJS.ReadableStream>({ | ||
| method: "POST", | ||
| path: `/functions/projects/${trigger.projectId}/triggers/${trigger.triggerName}`, | ||
| body: JSON.stringify(event), | ||
| responseType: "stream", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. q: why is the response type here stream?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not 100% of this either. I know it is showing up as a diff here, but that's just because I extracted out some of the previous code into a function. I am inclined to leave it this way as it is working fine with supporting the new events and I don't want to break anything that was working previously. |
||
| resolveOnHTTPError: true, | ||
| }) | ||
| .then((res) => { | ||
| // Since the response type is a stream and using `resolveOnHTTPError: true`, we check status manually. | ||
| if (res.status >= 400) { | ||
| throw new FirebaseError(`Received non-200 status code: ${res.status}`); | ||
| } | ||
| }) | ||
| .catch((err) => { | ||
| this.logger.log( | ||
| "ERROR", | ||
| `Failed to trigger Functions emulator for ${trigger.triggerName}: ${err}`, | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| private matchesAll(event: CloudEvent<any>, eventFilters: Record<string, string>): boolean { | ||
| return Object.entries(eventFilters).every(([key, value]) => { | ||
| let attr = event[key] ?? event.attributes[key]; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
*nit - how about EventarcTrigger? I don't feel strongly about this but it may make sense to apply a more specific naming convention, in case we add other event trigger interfaces for other emulated services.