Skip to content

Commit

Permalink
Merge branch 'master' into migrate-tslint-to-eslint
Browse files Browse the repository at this point in the history
  • Loading branch information
shisama committed Aug 12, 2020
2 parents 0a2e3c4 + 6c2dea0 commit baff466
Show file tree
Hide file tree
Showing 10 changed files with 184 additions and 24 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
@@ -1 +1,4 @@
- Fixes an issue where immutable params were not sent during ext:configure.
- Includes latest features and improvements from production in the Firestore Emulator.
- Fixes issue where all database functions triggered on the default namespace (#2501)
- Replace `tslint` with `eslint` as the default linter in new Cloud Functions for Firebase directories. (#1663)
6 changes: 3 additions & 3 deletions src/commands/ext-configure.ts
Expand Up @@ -65,9 +65,9 @@ export default new Command("ext:configure <extensionInstanceId>")
const plural = immutableParams.length > 1;
logger.info(`The following param${plural ? "s are" : " is"} immutable:`);
for (const { param } of immutableParams) {
logger.info(
`param: ${param}, value: ${_.get(existingInstance, `config.params.${param}`)}`
);
const value = _.get(existingInstance, `config.params.${param}`);
logger.info(`param: ${param}, value: ${value}`);
params[param] = value;
}
logger.info(
(plural
Expand Down
4 changes: 2 additions & 2 deletions src/commands/ext-install.ts
Expand Up @@ -150,7 +150,7 @@ export default new Command("ext:install [extensionName]")
if (options.interactive) {
learnMore = true;
extensionName = await promptForOfficialExtension(
"Which official extension do you want to install?\n" +
"Which official extension do you wish to install?\n" +
" Select an extension, then press Enter to learn more."
);
} else {
Expand Down Expand Up @@ -209,7 +209,7 @@ export default new Command("ext:install [extensionName]")
const confirm = await promptOnce({
type: "confirm",
default: true,
message: "Do you want to install this extension?",
message: "Do you wish to install this extension?",
});
if (!confirm) {
return;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/ext-uninstall.ts
Expand Up @@ -66,7 +66,7 @@ export default new Command("ext:uninstall <extensionInstanceId>")
const confirmedExtensionDeletion = await promptOnce({
type: "confirm",
default: true,
message: "Are you sure that you want to uninstall this extension?",
message: "Are you sure that you wish to uninstall this extension?",
});
if (!confirmedExtensionDeletion) {
return utils.reject("Command aborted.", { exit: 1 });
Expand Down
10 changes: 5 additions & 5 deletions src/emulator/downloadableEmulators.ts
Expand Up @@ -40,14 +40,14 @@ const DownloadDetails: { [s in DownloadableEmulators]: EmulatorDownloadDetails }
},
},
firestore: {
downloadPath: path.join(CACHE_DIR, "cloud-firestore-emulator-v1.11.5.jar"),
version: "1.11.5",
downloadPath: path.join(CACHE_DIR, "cloud-firestore-emulator-v1.11.7.jar"),
version: "1.11.7",
opts: {
cacheDir: CACHE_DIR,
remoteUrl:
"https://storage.googleapis.com/firebase-preview-drop/emulator/cloud-firestore-emulator-v1.11.5.jar",
expectedSize: 63287761,
expectedChecksum: "badd7007623ce51761d7a8ec2be42f8f",
"https://storage.googleapis.com/firebase-preview-drop/emulator/cloud-firestore-emulator-v1.11.7.jar",
expectedSize: 63857175,
expectedChecksum: "fd8577f82d42ee1c03ae9d12b888049c",
namePrefix: "cloud-firestore-emulator",
},
},
Expand Down
57 changes: 46 additions & 11 deletions src/emulator/functionsEmulator.ts
Expand Up @@ -47,8 +47,12 @@ const EVENT_INVOKE = "functions:invoke";
* definition to be relative to the database root. This regex is used to extract
* that path from the `resource` member in the trigger definition used by the
* functions emulator.
*
* Groups:
* 1 - instance
* 2 - path
*/
const DATABASE_PATH_PATTERN = new RegExp("^projects/[^/]+/instances/[^/]+/refs(/.*)$");
const DATABASE_PATH_PATTERN = new RegExp("^projects/[^/]+/instances/([^/]+)/refs(/.*)$");

export interface FunctionsEmulatorArgs {
projectId: string;
Expand Down Expand Up @@ -158,7 +162,7 @@ export class FunctionsEmulator implements EmulatorInstance {

// The URL for the function that the other emulators (Firestore, etc) use.
// TODO(abehaskins): Make the other emulators use the route below and remove this.
const backgroundFunctionRoute = `/functions/projects/${this.args.projectId}/triggers/:trigger_name`;
const backgroundFunctionRoute = `/functions/projects/:project_id/triggers/:trigger_name`;

// The URL that the developer sees, this is the same URL that the legacy emulator used.
const httpsFunctionRoute = `/${this.args.projectId}/:region/:trigger_name`;
Expand Down Expand Up @@ -189,6 +193,10 @@ export class FunctionsEmulator implements EmulatorInstance {
// all events.
hub.post(backgroundFunctionRoute, dataMiddleware, backgroundHandler);
hub.all(httpsFunctionRoutes, dataMiddleware, httpsHandler);
hub.all("*", dataMiddleware, (req, res) => {
logger.debug(`Functions emulator received unknown request at path ${req.path}`);
res.sendStatus(404);
});
return hub;
}

Expand Down Expand Up @@ -386,7 +394,7 @@ export class FunctionsEmulator implements EmulatorInstance {
}

const result: string[] | null = DATABASE_PATH_PATTERN.exec(definition.eventTrigger.resource);
if (result === null || result.length !== 2) {
if (result === null || result.length !== 3) {
this.logger.log(
"WARN",
`Event trigger "${definition.name}" has malformed "resource" member. ` +
Expand All @@ -395,18 +403,19 @@ export class FunctionsEmulator implements EmulatorInstance {
return Promise.reject();
}

const instance = result[1];
const bundle = JSON.stringify({
name: `projects/${projectId}/locations/_/functions/${definition.name}`,
path: result[1], // path stored in the first capture group
path: result[2], // path stored in the second capture group
event: definition.eventTrigger.eventType,
topic: `projects/${projectId}/topics/${definition.name}`,
});

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

let setTriggersPath = "/.settings/functionTriggers.json";
if (projectId !== "") {
setTriggersPath += `?ns=${projectId}`;
if (instance !== "") {
setTriggersPath += `?ns=${instance}`;
} else {
this.logger.log(
"WARN",
Expand Down Expand Up @@ -683,8 +692,37 @@ export class FunctionsEmulator implements EmulatorInstance {

private async handleBackgroundTrigger(req: express.Request, res: express.Response) {
const method = req.method;
const projectId = req.params.project_id;
const triggerId = req.params.trigger_name;

const trigger = this.getTriggerById(triggerId);
const service = getFunctionService(trigger);

if (projectId !== this.args.projectId) {
// RTDB considers each namespace a "project", but for any other trigger we want to reject
// incoming triggers to a different project.
if (service !== Constants.SERVICE_REALTIME_DATABASE) {
logger.debug(
`Received functions trigger for service "${service}" for unknown project "${projectId}".`
);
res.sendStatus(404);
return;
}

// The eventTrigger 'resource' property will look something like this:
// "projects/_/instances/<project>/refs/foo/bar"
// If the trigger's resource does not match the invoked projet ID, we should 404.
if (!trigger.eventTrigger!.resource.startsWith(`projects/_/instances/${projectId}`)) {
logger.debug(
`Received functions trigger for function "${triggerId}" of project "${projectId}" that did not match definition: ${JSON.stringify(
trigger
)}.`
);
res.sendStatus(404);
return;
}
}

this.logger.log("DEBUG", `Accepted request ${method} ${req.url} --> ${triggerId}`);

const reqBody = (req as RequestWithRawBody).rawBody;
Expand All @@ -699,10 +737,7 @@ export class FunctionsEmulator implements EmulatorInstance {
});

// For analytics, track the invoked service
if (triggerId) {
const trigger = this.getTriggerById(triggerId);
track(EVENT_INVOKE, getFunctionService(trigger));
}
track(EVENT_INVOKE, service);

await worker.waitForDone();
return res.json({ status: "acknowledged" });
Expand Down
20 changes: 20 additions & 0 deletions src/extensions/extensionsApi.ts
@@ -1,5 +1,7 @@
import * as yaml from "js-yaml";
import * as _ from "lodash";
import * as api from "../api";
import * as logger from "../logger";
import * as operationPoller from "../operation-poller";

const VERSION = "v1beta";
Expand Down Expand Up @@ -66,6 +68,7 @@ export interface Resource {
type: string;
description?: string;
properties?: { [key: string]: any };
propertiesYaml?: string;
}

export interface Author {
Expand Down Expand Up @@ -284,6 +287,21 @@ async function patchInstance(
return pollRes;
}

function populateResourceProperties(source: ExtensionSource): void {
const spec: ExtensionSpec = source.spec;
if (spec) {
spec.resources.forEach((r) => {
try {
if (r.propertiesYaml) {
r.properties = yaml.safeLoad(r.propertiesYaml);
}
} catch (err) {
logger.debug(`[ext] failed to parse resource properties yaml: ${err}`);
}
});
}
}

/**
* Create a new extension source
*
Expand All @@ -310,6 +328,7 @@ export async function createSource(
operationResourceName: createRes.body.name,
masterTimeout: 600000,
});
populateResourceProperties(pollRes);
return pollRes;
}

Expand All @@ -324,6 +343,7 @@ export function getSource(sourceName: string): Promise<ExtensionSource> {
origin: api.extensionsOrigin,
})
.then((res) => {
populateResourceProperties(res.body);
return res.body;
});
}
2 changes: 1 addition & 1 deletion src/extensions/extensionsHelper.ts
Expand Up @@ -400,7 +400,7 @@ export async function promptForRepeatInstance(
): Promise<string> {
const message =
`An extension with the ID ${extensionName} already exists in the project ${projectName}.\n` +
`Do you want to proceed with installing another instance of ${extensionName} in this project?`;
`Do you wish to proceed with installing another instance of ${extensionName} in this project?`;
return await promptOnce({
type: "confirm",
message,
Expand Down
2 changes: 1 addition & 1 deletion src/extensions/updateHelper.ts
Expand Up @@ -201,7 +201,7 @@ export async function displayChanges(
export async function retryUpdate(): Promise<boolean> {
return promptOnce({
type: "confirm",
message: "Are you sure you want to continue with updating anyways?",
message: "Are you sure you wish to continue with updating anyways?",
default: false,
});
}
Expand Down
102 changes: 102 additions & 0 deletions src/test/extensions/extensionsApi.spec.ts
Expand Up @@ -45,6 +45,35 @@ TEST_INSTANCES_RESPONSE_NEXT_PAGE_TOKEN.nextPageToken = "abc123";
const PROJECT_ID = "test-project";
const INSTANCE_ID = "test-extensions-instance";

const PACKAGE_URI = "https://storage.googleapis.com/ABCD.zip";
const SOURCE_NAME = "projects/firebasemods/sources/abcd";
const TEST_SOURCE = {
name: SOURCE_NAME,
packageUri: PACKAGE_URI,
hash: "deadbeef",
spec: {
name: "test",
displayName: "Old",
description: "descriptive",
version: "1.0.0",
license: "MIT",
resources: [
{
name: "resource1",
type: "firebaseextensions.v1beta.function",
description: "desc",
propertiesYaml:
"eventTrigger:\n eventType: providers/cloud.firestore/eventTypes/document.write\n resource: projects/${PROJECT_ID}/databases/(default)/documents/${COLLECTION_PATH}/{documentId}\nlocation: ${LOCATION}",
},
],
author: { authorName: "Tester" },
contributors: [{ authorName: "Tester 2" }],
billingRequired: true,
sourceUrl: "test.com",
params: [],
},
};

describe("extensions", () => {
beforeEach(() => {
helpers.mockAuth(sinon);
Expand Down Expand Up @@ -352,4 +381,77 @@ describe("extensions", () => {
expect(nock.isDone()).to.be.true;
});
});

describe("getSource", () => {
afterEach(() => {
nock.cleanAll();
});

it("should make a GET call to the correct endpoint", async () => {
nock(api.extensionsOrigin)
.get(`/${VERSION}/${SOURCE_NAME}`)
.reply(200, TEST_SOURCE);

const source = await extensionsApi.getSource(SOURCE_NAME);
expect(nock.isDone()).to.be.true;
expect(source.spec.resources).to.have.lengthOf(1);
expect(source.spec.resources[0]).to.have.property("properties");
});

it("should throw a FirebaseError if the endpoint returns an error response", async () => {
nock(api.extensionsOrigin)
.get(`/${VERSION}/${SOURCE_NAME}`)
.reply(404);

await expect(extensionsApi.getSource(SOURCE_NAME)).to.be.rejectedWith(FirebaseError);
expect(nock.isDone()).to.be.true;
});
});

describe("createSource", () => {
afterEach(() => {
nock.cleanAll();
});

it("should make a POST call to the correct endpoint, and then poll on the returned operation", async () => {
nock(api.extensionsOrigin)
.post(`/${VERSION}/projects/${PROJECT_ID}/sources/`)
.reply(200, { name: "operations/abc123" });
nock(api.extensionsOrigin)
.get(`/${VERSION}/operations/abc123`)
.reply(200, { done: true, response: TEST_SOURCE });

const source = await extensionsApi.createSource(PROJECT_ID, PACKAGE_URI, ",./");
expect(nock.isDone()).to.be.true;
expect(source.spec.resources).to.have.lengthOf(1);
expect(source.spec.resources[0]).to.have.property("properties");
});

it("should throw a FirebaseError if create returns an error response", async () => {
nock(api.extensionsOrigin)
.post(`/${VERSION}/projects/${PROJECT_ID}/sources/`)
.reply(500);

await expect(extensionsApi.createSource(PROJECT_ID, PACKAGE_URI, "./")).to.be.rejectedWith(
FirebaseError,
"HTTP Error: 500, Unknown Error"
);
expect(nock.isDone()).to.be.true;
});

it("stop polling and throw if the operation call throws an unexpected error", async () => {
nock(api.extensionsOrigin)
.post(`/${VERSION}/projects/${PROJECT_ID}/sources/`)
.reply(200, { name: "operations/abc123" });
nock(api.extensionsOrigin)
.get(`/${VERSION}/operations/abc123`)
.reply(502);

await expect(extensionsApi.createSource(PROJECT_ID, PACKAGE_URI, "./")).to.be.rejectedWith(
FirebaseError,
"HTTP Error: 502, Unknown Error"
);
expect(nock.isDone()).to.be.true;
});
});
});

0 comments on commit baff466

Please sign in to comment.