Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
- GitHub Action fixes for web frameworks (#6883)
- Fixes issue where PubSub message `publishTime` is set to 1970-01-01T00:00:00 (#7441)
- Display meaningful error message when cannot determine target. (#6594)
- Adds support for firealerts events in Eventarc emulator. (#7355)
- Improved errors when an incorrect service ID is passed to `firebase deploy --only dataconnect:serviceId`.
2 changes: 1 addition & 1 deletion firebase-vscode/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/emulator/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export class Constants {
static SERVICE_REALTIME_DATABASE = "firebaseio.com";
static SERVICE_PUBSUB = "pubsub.googleapis.com";
static SERVICE_EVENTARC = "eventarc.googleapis.com";
static SERVICE_FIREALERTS = "firebasealerts.googleapis.com";
// Note: the service name below are here solely for logging purposes.
// There is not an emulator available for these.
static SERVICE_ANALYTICS = "app-measurement.com";
Expand Down
147 changes: 105 additions & 42 deletions src/emulator/eventarcEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Copy Markdown
Contributor

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.

projectId: string;
triggerName: string;
eventTrigger: EventTrigger;
Expand All @@ -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);
Expand All @@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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) {
Expand All @@ -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);
};
Expand All @@ -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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: why is the response type here stream?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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];
Expand Down
Loading