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

Adds emulator support for v2 rtdb triggers #5045

Merged
merged 8 commits into from Oct 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
@@ -0,0 +1 @@
- Add functions emulator support for RTDB v2 triggers (#5045).
6 changes: 6 additions & 0 deletions scripts/integration-helpers/framework.ts
Expand Up @@ -23,6 +23,7 @@ const STORAGE_BUCKET_FUNCTION_V2_FINALIZED_LOG =
"========== STORAGE BUCKET V2 FUNCTION FINALIZED ==========";
const STORAGE_BUCKET_FUNCTION_V2_METADATA_LOG =
"========== STORAGE BUCKET V2 FUNCTION METADATA ==========";
const RTDB_V2_FUNCTION_LOG = "========== RTDB V2 FUNCTION ==========";
/* Functions V1 */
const RTDB_FUNCTION_LOG = "========== RTDB FUNCTION ==========";
const FIRESTORE_FUNCTION_LOG = "========== FIRESTORE FUNCTION ==========";
Expand Down Expand Up @@ -141,6 +142,7 @@ export class TriggerEndToEndTest extends EmulatorEndToEndTest {
storageBucketV2MetadataTriggerCount = 0;
authBlockingCreateV2TriggerCount = 0;
authBlockingSignInV2TriggerCount = 0;
rtdbV2TriggerCount = 0;

rtdbFromFirestore = false;
firestoreFromRtdb = false;
Expand Down Expand Up @@ -176,6 +178,7 @@ export class TriggerEndToEndTest extends EmulatorEndToEndTest {
this.storageBucketV2MetadataTriggerCount = 0;
this.authBlockingCreateV2TriggerCount = 0;
this.authBlockingSignInV2TriggerCount = 0;
this.rtdbV2TriggerCount = 0;
}

/*
Expand Down Expand Up @@ -268,6 +271,9 @@ export class TriggerEndToEndTest extends EmulatorEndToEndTest {
if (data.includes(AUTH_BLOCKING_SIGN_IN_V2_LOG)) {
this.authBlockingSignInV2TriggerCount++;
}
if (data.includes(RTDB_V2_FUNCTION_LOG)) {
this.rtdbV2TriggerCount++;
}
});

return startEmulators;
Expand Down
1 change: 1 addition & 0 deletions scripts/triggers-end-to-end-tests/tests.ts
Expand Up @@ -148,6 +148,7 @@ describe("function triggers", () => {

it("should have have triggered cloud functions", () => {
expect(test.rtdbTriggerCount).to.equal(1);
expect(test.rtdbV2TriggerCount).to.eq(1);
expect(test.firestoreTriggerCount).to.equal(1);
/*
* Check for the presence of all expected documents in the firestore
Expand Down
14 changes: 7 additions & 7 deletions scripts/triggers-end-to-end-tests/triggers/package-lock.json

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

2 changes: 1 addition & 1 deletion scripts/triggers-end-to-end-tests/triggers/package.json
Expand Up @@ -10,7 +10,7 @@
"@google-cloud/pubsub": "^3.0.1",
"firebase": "^9.9.0",
"firebase-admin": "^11.0.0",
"firebase-functions": "^3.22.0"
"firebase-functions": "^3.24.1"
},
"devDependencies": {
"firebase-functions-test": "^0.2.0"
Expand Down
14 changes: 7 additions & 7 deletions scripts/triggers-end-to-end-tests/v1/package-lock.json

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

2 changes: 1 addition & 1 deletion scripts/triggers-end-to-end-tests/v1/package.json
Expand Up @@ -8,7 +8,7 @@
"dependencies": {
"@firebase/database-compat": "0.1.2",
"firebase-admin": "^11.0.0",
"firebase-functions": "^3.22.0"
"firebase-functions": "^3.24.1"
},
"devDependencies": {
"firebase-functions-test": "^0.2.0"
Expand Down
7 changes: 7 additions & 0 deletions scripts/triggers-end-to-end-tests/v2/index.js
Expand Up @@ -22,8 +22,10 @@ const AUTH_BLOCKING_CREATE_V2_LOG =
"========== AUTH BLOCKING CREATE V2 FUNCTION METADATA ==========";
const AUTH_BLOCKING_SIGN_IN_V2_LOG =
"========== AUTH BLOCKING SIGN IN V2 FUNCTION METADATA ==========";
const RTDB_LOG = "========== RTDB V2 FUNCTION ==========";

const PUBSUB_TOPIC = "test-topic";
const START_DOCUMENT_NAME = "test/start";

admin.initializeApp();

Expand Down Expand Up @@ -125,3 +127,8 @@ exports.onreqv2timeout = functionsV2.https.onRequest({ timeoutSeconds: 1 }, asyn
}, 3_000);
});
});

exports.rtdbv2reaction = functionsV2.database.onValueWritten(START_DOCUMENT_NAME, (event) => {
console.log(RTDB_LOG);
return;
});
14 changes: 7 additions & 7 deletions scripts/triggers-end-to-end-tests/v2/package-lock.json

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

2 changes: 1 addition & 1 deletion scripts/triggers-end-to-end-tests/v2/package.json
Expand Up @@ -7,7 +7,7 @@
},
"dependencies": {
"firebase-admin": "^11.0.0",
"firebase-functions": "^3.22.0"
"firebase-functions": "^3.24.1"
},
"devDependencies": {
"firebase-functions-test": "^0.2.0"
Expand Down
87 changes: 66 additions & 21 deletions src/emulator/functionsEmulator.ts
Expand Up @@ -589,7 +589,9 @@ export class FunctionsEmulator implements EmulatorInstance {
added = await this.addRealtimeDatabaseTrigger(
this.args.projectId,
key,
definition.eventTrigger
definition.eventTrigger,
signature,
definition.region
);
break;
case Constants.SERVICE_PUBSUB:
Expand Down Expand Up @@ -721,21 +723,12 @@ export class FunctionsEmulator implements EmulatorInstance {
}
}

async addRealtimeDatabaseTrigger(
projectId: string,
key: string,
eventTrigger: EventTrigger
): Promise<boolean> {
const databaseEmu = EmulatorRegistry.get(Emulators.DATABASE);
if (!databaseEmu) {
return false;
}

const result: string[] | null = DATABASE_PATH_PATTERN.exec(eventTrigger.resource);
private getV1DatabaseApiAttributes(projectId: string, key: string, eventTrigger: EventTrigger) {
const result: string[] | null = DATABASE_PATH_PATTERN.exec(eventTrigger.resource!);
if (result === null || result.length !== 3) {
this.logger.log(
"WARN",
`Event function "${key}" has malformed "resource" member. ` + `${eventTrigger.resource}`
`Event function "${key}" has malformed "resource" member. ` + `${eventTrigger.resource!}`
);
throw new FirebaseError(`Event function ${key} has malformed resource member`);
}
Expand All @@ -748,24 +741,76 @@ export class FunctionsEmulator implements EmulatorInstance {
topic: `projects/${projectId}/topics/${key}`,
});

logger.debug(`addRealtimeDatabaseTrigger[${instance}]`, JSON.stringify(bundle));

let setTriggersPath = "/.settings/functionTriggers.json";
let apiPath = "/.settings/functionTriggers.json";
if (instance !== "") {
setTriggersPath += `?ns=${instance}`;
apiPath += `?ns=${instance}`;
} else {
this.logger.log(
"WARN",
`No project in use. Registering function for sentinel namespace '${Constants.DEFAULT_DATABASE_EMULATOR_NAMESPACE}'`
);
}

return { bundle, apiPath, instance };
}

private getV2DatabaseApiAttributes(
projectId: string,
key: string,
eventTrigger: EventTrigger,
region: string
) {
const instance =
eventTrigger.eventFilters?.instance || eventTrigger.eventFilterPathPatterns?.instance;
if (!instance) {
throw new FirebaseError("A database instance must be supplied.");
}

const ref = eventTrigger.eventFilterPathPatterns?.ref;
if (!ref) {
throw new FirebaseError("A database reference must be supplied.");
Copy link
Contributor

Choose a reason for hiding this comment

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

q: What does it mean for these values to not exist (in other words - can these conditions be triggered if users are using the function sdk correctly?)

Copy link
Member

Choose a reason for hiding this comment

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

No, but I appreciate treating this as untrusted input. We could have new SDK authors pop these assertions because they have bugs in their implementation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Correct, using the Functions SDK as it currently is will never achieve this state

}

// The 'namespacePattern' determines that we are using the v2 interface
const bundle = JSON.stringify({
name: `projects/${projectId}/locations/${region}/triggers/${key}`,
path: ref,
event: eventTrigger.eventType,
topic: `projects/${projectId}/topics/${key}`,
namespacePattern: instance,
});

// The query parameter '?ns=${instance}' is ignored in v2
const apiPath = "/.settings/functionTriggers.json";
Copy link
Contributor

Choose a reason for hiding this comment

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

does difference in how we construct the apiPath for v1 and v2 mean that v2 rtdb emulation only works on the default RTDB instance? or existence of namespacePattern imply that it's a v2 trigger?

(whatever ends up being true - can we add a comment explaining this?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

namespacePattern implies that we are using the v2 interface. Under the hood, the ?ns=... query param will be ignored for v2 triggers, but I figured it would be better to show that explicitly here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a comment explaining this


return { bundle, apiPath, instance };
}

async addRealtimeDatabaseTrigger(
projectId: string,
key: string,
eventTrigger: EventTrigger,
signature: SignatureType,
region: string
): Promise<boolean> {
const databaseEmu = EmulatorRegistry.get(Emulators.DATABASE);
if (!databaseEmu) {
return false;
}

const { bundle, apiPath, instance } =
signature === "cloudevent"
? this.getV2DatabaseApiAttributes(projectId, key, eventTrigger, region)
: this.getV1DatabaseApiAttributes(projectId, key, eventTrigger);

logger.debug(`addRealtimeDatabaseTrigger[${instance}]`, JSON.stringify(bundle));

const client = new Client({
urlPrefix: `http://${EmulatorRegistry.getInfoHostString(databaseEmu.getInfo())}`,
auth: false,
});
try {
await client.post(setTriggersPath, bundle, { headers: { Authorization: "Bearer owner" } });
await client.post(apiPath, bundle, { headers: { Authorization: "Bearer owner" } });
} catch (err: any) {
this.logger.log("WARN", "Error adding Realtime Database function: " + err);
throw err;
Expand Down Expand Up @@ -819,7 +864,7 @@ export class FunctionsEmulator implements EmulatorInstance {
logger.debug(`addPubsubTrigger`, JSON.stringify({ eventTrigger }));

// "resource":\"projects/{PROJECT_ID}/topics/{TOPIC_ID}";
const resource = eventTrigger.resource;
const resource = eventTrigger.resource!;
Copy link
Contributor

Choose a reason for hiding this comment

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

is this ! necessary here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We make def.resource string | undefined now

let topic;
if (schedule) {
// In production this topic looks like
Expand Down Expand Up @@ -852,8 +897,8 @@ export class FunctionsEmulator implements EmulatorInstance {
addStorageTrigger(projectId: string, key: string, eventTrigger: EventTrigger): boolean {
logger.debug(`addStorageTrigger`, JSON.stringify({ eventTrigger }));

const bucket = eventTrigger.resource.startsWith("projects/_/buckets/")
? eventTrigger.resource.split("/")[3]
const bucket = eventTrigger.resource!.startsWith("projects/_/buckets/")
? eventTrigger.resource!.split("/")[3]
: eventTrigger.resource;
const eventTriggerId = `${projectId}:${eventTrigger.eventType}:${bucket}`;
const triggers = this.multicastTriggers[eventTriggerId] || [];
Expand Down
8 changes: 3 additions & 5 deletions src/emulator/functionsEmulatorRuntime.ts
Expand Up @@ -1048,11 +1048,9 @@ async function main(): Promise<void> {
case "cloudevent":
const rawBody = (req as RequestWithRawBody).rawBody;
let reqBody = JSON.parse(rawBody.toString());
if (req.headers["content-type"]?.includes("cloudevent")) {
if (EventUtils.isBinaryCloudEvent(req)) {
reqBody = EventUtils.extractBinaryCloudEventContext(req);
reqBody.data = req.body;
}
if (EventUtils.isBinaryCloudEvent(req)) {
reqBody = EventUtils.extractBinaryCloudEventContext(req);
reqBody.data = req.body;
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if we actually wanted a third option: check for contenttype to include cloudevent but then throw that structured encoding is not supported if isBinaryCloudEvent is false.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We are actually removing the check for content type to include cloudevent since it's not in the spec - https://cloud.google.com/eventarc/docs/workflows/cloudevents#payload-format

}
await processBackground(trigger, reqBody, FUNCTION_SIGNATURE);
res.send({ status: "acknowledged" });
Expand Down