diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f99216a3..bf5c96e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ +- feat: Add requiresAPI function to allow declaring Google Cloud API dependencies in code. - fix(v1): Call onInit for schedule.onRun functions (#1801) diff --git a/spec/common/api.spec.ts b/spec/common/api.spec.ts new file mode 100644 index 000000000..e1bb943e8 --- /dev/null +++ b/spec/common/api.spec.ts @@ -0,0 +1,27 @@ +import { expect } from "chai"; +import { requiresAPI, getGlobalRequiredAPIs, clearGlobalRequiredAPIs } from "../../src/common/api"; + +describe("requiresAPI", () => { + afterEach(() => { + clearGlobalRequiredAPIs(); + }); + + it("stores required API", () => { + requiresAPI("test.googleapis.com", "reason"); + expect(getGlobalRequiredAPIs()).to.deep.equal([ + { api: "test.googleapis.com", reason: "reason" }, + ]); + }); + + it("throws error for invalid API", () => { + expect(() => requiresAPI("" as any, "reason")).to.throw( + "requiresAPI: 'api' must be a non-empty string ending with '.googleapis.com'." + ); + expect(() => requiresAPI(null as any, "reason")).to.throw( + "requiresAPI: 'api' must be a non-empty string ending with '.googleapis.com'." + ); + expect(() => requiresAPI("invalid-api" as any, "reason")).to.throw( + "requiresAPI: 'api' must be a non-empty string ending with '.googleapis.com'." + ); + }); +}); diff --git a/spec/fixtures/sources/requiresapi/index.js b/spec/fixtures/sources/requiresapi/index.js new file mode 100644 index 000000000..fde2180e8 --- /dev/null +++ b/spec/fixtures/sources/requiresapi/index.js @@ -0,0 +1,8 @@ +const { requiresAPI } = require("../../../../src/v2"); +const functions = require("../../../../src/v1"); + +requiresAPI("some-api.googleapis.com", "Needed for some reason"); + +exports.v1http = functions.https.onRequest((req, resp) => { + resp.status(200).send("PASS"); +}); diff --git a/spec/runtime/loader.spec.ts b/spec/runtime/loader.spec.ts index e67140c1b..9070c995f 100644 --- a/spec/runtime/loader.spec.ts +++ b/spec/runtime/loader.spec.ts @@ -10,6 +10,7 @@ import { ManifestStack, } from "../../src/runtime/manifest"; import { clearParams } from "../../src/params"; +import { clearGlobalRequiredAPIs } from "../../src/common/api"; import { MINIMAL_V1_ENDPOINT, MINIMAL_V2_ENDPOINT } from "../fixtures"; import { MINIMAL_SCHEDULE_TRIGGER, MINIMIAL_TASK_QUEUE_TRIGGER } from "../v1/providers/fixtures"; import { BooleanParam, IntParam, StringParam } from "../../src/params/types"; @@ -341,6 +342,15 @@ describe("loadStack", () => { afterEach(() => { process.env.GCLOUD_PROJECT = prev; + clearGlobalRequiredAPIs(); + clearParams(); + // Purge the require cache for fixture modules so that when a file is loaded + // a second time via absolute path, it re-executes and successfully runs its global side-effects. + for (const key of Object.keys(require.cache)) { + if (key.includes("fixtures/sources")) { + delete require.cache[key]; + } + } }); describe("commonjs", () => { @@ -468,6 +478,28 @@ describe("loadStack", () => { }, }, }, + { + name: "requires api", + modulePath: "./spec/fixtures/sources/requiresapi", + expected: { + endpoints: { + v1http: { + ...MINIMAL_V1_ENDPOINT, + platform: "gcfv1", + entryPoint: "v1http", + httpsTrigger: {}, + }, + }, + requiredAPIs: [ + { + api: "some-api.googleapis.com", + reason: "Needed for some reason", + }, + ], + extensions: {}, + specVersion: "v1alpha1", + }, + }, ]; for (const tc of testcases) { diff --git a/src/common/api.ts b/src/common/api.ts new file mode 100644 index 000000000..7ebcd9671 --- /dev/null +++ b/src/common/api.ts @@ -0,0 +1,53 @@ +// The MIT License (MIT) +// +// Copyright (c) 2024 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { ManifestRequiredAPI } from "../runtime/manifest"; + +let globalRequiredAPIs: ManifestRequiredAPI[] = []; + +export type GoogleCloudApi = `${string}.googleapis.com`; + +/** + * Declare that this project requires a specific Google Cloud API to be enabled. + * @param api The API name, e.g. "secretmanager.googleapis.com" + * @param reason Optional reason why the API is needed. + */ +export function requiresAPI(api: GoogleCloudApi, reason = ""): void { + if (!api || typeof api !== "string" || !api.endsWith(".googleapis.com")) { + throw new Error("requiresAPI: 'api' must be a non-empty string ending with '.googleapis.com'."); + } + globalRequiredAPIs.push({ api, reason }); +} + +/** + * @internal + */ +export function getGlobalRequiredAPIs(): ManifestRequiredAPI[] { + return globalRequiredAPIs; +} + +/** + * @internal + */ +export function clearGlobalRequiredAPIs(): void { + globalRequiredAPIs = []; +} diff --git a/src/runtime/loader.ts b/src/runtime/loader.ts index 5c7af9553..5b8d7d585 100644 --- a/src/runtime/loader.ts +++ b/src/runtime/loader.ts @@ -30,6 +30,7 @@ import { } from "./manifest"; import * as params from "../params"; +import { getGlobalRequiredAPIs, clearGlobalRequiredAPIs } from "../common/api"; /** * Dynamically load import function to prevent TypeScript from @@ -193,6 +194,8 @@ export async function loadStack(functionsDir: string): Promise { const mod = await loadModule(functionsDir); extractStack(mod, endpoints, requiredAPIs, extensions); + requiredAPIs.push(...getGlobalRequiredAPIs()); + clearGlobalRequiredAPIs(); const stack: ManifestStack = { endpoints, diff --git a/src/v1/index.ts b/src/v1/index.ts index 7f3f9e10b..9492fa091 100644 --- a/src/v1/index.ts +++ b/src/v1/index.ts @@ -61,3 +61,4 @@ import * as params from "../params"; export { params }; export { onInit } from "../common/onInit"; +export { requiresAPI } from "../common/api"; diff --git a/src/v2/index.ts b/src/v2/index.ts index 8fb5c7400..4b70d8215 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -63,6 +63,7 @@ export { export { logger } from "../logger"; export { setGlobalOptions } from "./options"; +export { requiresAPI } from "../common/api"; export type { GlobalOptions, SupportedRegion,