Skip to content
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

Add support for firealerts events in Eventarc emulator. #7355

Merged
merged 18 commits into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

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 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
88 changes: 54 additions & 34 deletions src/emulator/eventarcEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
import { EmulatorRegistry } from "./registry";
import { FirebaseError } from "../error";
import { cloudEventFromProtoToJson } from "./eventarcEmulatorUtils";
import * as cors from "cors";

interface CustomEventTrigger {
interface EmulatedEventTrigger {
Copy link
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,12 +25,13 @@
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) {}

Expand All @@ -46,40 +48,53 @@
}
const bodyString = (req as RequestWithRawBody).rawBody.toString();
const substituted = bodyString.replaceAll("${PROJECT_ID}", projectId);
const body = JSON.parse(substituted);

Check warning on line 51 in src/emulator/eventarcEmulator.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
const eventTrigger = body.eventTrigger as EventTrigger;

Check warning on line 52 in src/emulator/eventarcEmulator.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .eventTrigger on an `any` value
if (!eventTrigger) {
const error = `Missing event trigger for ${triggerName}.`;
this.logger.log("ERROR", error);
res.status(400).send({ error });
return;
}
const key = `${eventTrigger.eventType}-${eventTrigger.channel}`;
const channel = eventTrigger.channel || GOOGLE_CHANNEL;
const key = `${eventTrigger.eventType}-${channel}`;
this.logger.logLabeled(
"BULLET",
"eventarc",
`Registering custom event trigger for ${key} with trigger name ${triggerName}.`,
`Registering Eventarc event trigger for ${key} with trigger name ${triggerName}.`,
);
const customEventTriggers = this.customEvents[key] || [];
customEventTriggers.push({ projectId, triggerName, eventTrigger });
this.customEvents[key] = customEventTriggers;
const eventTriggers = this.events[key] || [];
eventTriggers.push({ projectId, triggerName, eventTrigger });
this.events[key] = eventTriggers;
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
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());

Check warning on line 87 in src/emulator/eventarcEmulator.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
for (const event of body.events) {

Check warning on line 88 in src/emulator/eventarcEmulator.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .events on an `any` value
if (!event.type) {

Check warning on line 89 in src/emulator/eventarcEmulator.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .type on an `any` value
res.sendStatus(400);
return;
}
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);

Check warning on line 97 in src/emulator/eventarcEmulator.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 97 in src/emulator/eventarcEmulator.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `CloudEvent<any>`
}
res.sendStatus(200);
};
Expand All @@ -98,52 +113,57 @@
const hub = express();
hub.post([registerTriggerRoute], dataMiddleware, registerTriggerHandler);
hub.post([publishEventsRoute], dataMiddleware, publishEventsHandler);
hub.post([publishNativeEventsRoute], dataMiddleware, publishEventsHandler);
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[]> {

Check warning on line 125 in src/emulator/eventarcEmulator.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (!EmulatorRegistry.isRunning(Emulators.FUNCTIONS)) {
this.logger.log("INFO", "Functions emulator not found. This should not happen.");
return Promise.reject();

Check warning on line 128 in src/emulator/eventarcEmulator.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Expected the Promise rejection reason to be an Error
}
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> {

Check warning on line 144 in src/emulator/eventarcEmulator.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
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
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
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
31 changes: 31 additions & 0 deletions src/emulator/functionsEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,13 @@ export class FunctionsEmulator implements EmulatorInstance {
case Constants.SERVICE_STORAGE:
added = this.addStorageTrigger(this.args.projectId, key, definition.eventTrigger);
break;
case Constants.SERVICE_FIREALERTS:
added = await this.addFirealertsTrigger(
this.args.projectId,
key,
definition.eventTrigger,
);
break;
default:
this.logger.log("DEBUG", `Unsupported trigger: ${JSON.stringify(definition)}`);
break;
Expand Down Expand Up @@ -768,6 +775,30 @@ export class FunctionsEmulator implements EmulatorInstance {
});
}

addFirealertsTrigger(
projectId: string,
key: string,
eventTrigger: EventTrigger,
): Promise<boolean> {
if (!EmulatorRegistry.isRunning(Emulators.EVENTARC)) {
return Promise.resolve(false);
}
const bundle = {
eventTrigger: {
...eventTrigger,
service: "firebasealerts.googleapis.com",
},
};
logger.debug(`addFirealertsTrigger`, JSON.stringify(bundle));
return EmulatorRegistry.client(Emulators.EVENTARC)
.post(`/emulator/v1/projects/${projectId}/triggers/${key}`, bundle)
.then(() => true)
.catch((err) => {
this.logger.log("WARN", "Error adding FireAlerts function: " + err);
return false;
});
}

async performPostLoadOperations(): Promise<void> {
if (
!this.blockingFunctionsConfig.triggers &&
Expand Down
4 changes: 4 additions & 0 deletions src/emulator/functionsEmulatorShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { connectableHostname } from "../utils";
/** The current v2 events that are implemented in the emulator */
const V2_EVENTS = [
events.v2.PUBSUB_PUBLISH_EVENT,
events.v2.FIREALERTS_EVENT,
...events.v2.STORAGE_EVENTS,
...events.v2.DATABASE_EVENTS,
...events.v2.FIRESTORE_EVENTS,
Expand Down Expand Up @@ -367,6 +368,9 @@ export function getServiceFromEventType(eventType: string): string {
if (eventType.includes("storage")) {
return Constants.SERVICE_STORAGE;
}
if (eventType.includes("firebasealerts")) {
return Constants.SERVICE_FIREALERTS;
}
// Below this point are services that do not have a emulator.
if (eventType.includes("analytics")) {
return Constants.SERVICE_ANALYTICS;
Expand Down
6 changes: 5 additions & 1 deletion src/functions/events/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export const FIRESTORE_EVENTS = [
"google.cloud.firestore.document.v1.updated.withAuthContext",
"google.cloud.firestore.document.v1.deleted.withAuthContext",
] as const;

export const FIREALERTS_EVENT = "google.firebase.firebasealerts.alerts.v1.published";

export const FIRESTORE_EVENT_REGEX = /^google\.cloud\.firestore\.document\.v1\.[^\.]*$/;
export const FIRESTORE_EVENT_WITH_AUTH_CONTEXT_REGEX =
/^google\.cloud\.firestore\.document\.v1\..*\.withAuthContext$/;
Expand All @@ -41,4 +44,5 @@ export type Event =
| (typeof DATABASE_EVENTS)[number]
| typeof REMOTE_CONFIG_EVENT
| typeof TEST_LAB_EVENT
| (typeof FIRESTORE_EVENTS)[number];
| (typeof FIRESTORE_EVENTS)[number]
| typeof FIREALERTS_EVENT;
Loading