diff --git a/src/commands/init.ts b/src/commands/init.ts index 5f2055764db..2839241db16 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -94,6 +94,12 @@ let choices: { checked: false, hidden: true, }, + { + value: "dataconnect:resolver", + name: "Data Connect: Set up a custom resolver for your Firebase Data Connect service", + checked: false, + hidden: true, + }, ]; if (isEnabled("genkit")) { diff --git a/src/init/features/dataconnect/index.ts b/src/init/features/dataconnect/index.ts index c02ac99d43e..cc44fc42123 100644 --- a/src/init/features/dataconnect/index.ts +++ b/src/init/features/dataconnect/index.ts @@ -61,6 +61,7 @@ const SEED_DATA_TEMPLATE = readTemplateSync("init/dataconnect/seed_data.gql"); export type Source = | "mcp_init" | "init" + | "init_schema" | "init_sdk" | "gen_sdk_init" | "gen_sdk_init_sdk" diff --git a/src/init/features/dataconnect/resolver.spec.ts b/src/init/features/dataconnect/resolver.spec.ts new file mode 100644 index 00000000000..0dbab3c5aa9 --- /dev/null +++ b/src/init/features/dataconnect/resolver.spec.ts @@ -0,0 +1,282 @@ +import * as chai from "chai"; +import * as clc from "colorette"; +import * as fs from "fs-extra"; +import * as yaml from "js-yaml"; +import * as sinon from "sinon"; + +import { + addSchemaToDataConnectYaml, + askQuestions, + actuate, + ResolverRequiredInfo, +} from "./resolver"; +import { Setup } from "../.."; +import { Config } from "../../../config"; +import * as load from "../../../dataconnect/load"; +import { DataConnectYaml, ServiceInfo } from "../../../dataconnect/types"; +import * as experiments from "../../../experiments"; +import * as prompt from "../../../prompt"; + +const expect = chai.expect; + +describe("addSchemaToDataConnectYaml", () => { + let schemaRequiredInfo: ResolverRequiredInfo; + let dataConnectYaml: DataConnectYaml; + + beforeEach(() => { + dataConnectYaml = { + location: "us-central1", + serviceId: "service-id", + connectorDirs: [], + }; + schemaRequiredInfo = { + id: "test_resolver", + uri: "www.test.com", + serviceInfo: {} as ServiceInfo, + }; + }); + + it("add schema to dataconnect.yaml with `schema` field", () => { + dataConnectYaml.schema = { + source: "./schema", + datasource: {}, + }; + addSchemaToDataConnectYaml(dataConnectYaml, schemaRequiredInfo); + expect(dataConnectYaml.schema).to.be.undefined; + expect(dataConnectYaml.schemas).to.have.lengthOf(2); + expect(dataConnectYaml.schemas).to.deep.equal([ + { + source: "./schema", + datasource: {}, + }, + { + source: "./schema_test_resolver", + id: "test_resolver", + datasource: { + httpGraphql: { + uri: "www.test.com", + }, + }, + }, + ]); + }); + it("add schema to dataconnect.yaml with `schemas` field", () => { + dataConnectYaml.schemas = [ + { + source: "./schema", + datasource: {}, + }, + { + source: "./schema_existing", + datasource: {}, + }, + ]; + addSchemaToDataConnectYaml(dataConnectYaml, schemaRequiredInfo); + expect(dataConnectYaml.schema).to.be.undefined; + expect(dataConnectYaml.schemas).to.have.lengthOf(3); + expect(dataConnectYaml.schemas).to.deep.equal([ + { + source: "./schema", + datasource: {}, + }, + { + source: "./schema_existing", + datasource: {}, + }, + { + source: "./schema_test_resolver", + id: "test_resolver", + datasource: { + httpGraphql: { + uri: "www.test.com", + }, + }, + }, + ]); + }); +}); + +describe("askQuestions", () => { + let setup: Setup; + let config: Config; + let experimentsStub: sinon.SinonStub; + let loadAllStub: sinon.SinonStub; + let selectStub: sinon.SinonStub; + let inputStub: sinon.SinonStub; + + beforeEach(() => { + setup = { + config: {} as any, + rcfile: {} as any, + instructions: [], + }; + config = new Config({}, { projectDir: "." }); + experimentsStub = sinon.stub(experiments, "isEnabled"); + loadAllStub = sinon.stub(load, "loadAll"); + selectStub = sinon.stub(prompt, "select"); + inputStub = sinon.stub(prompt, "input"); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should throw error when fdcwebhooks experiment is not enabled", async () => { + experimentsStub.returns(false); + + try { + await askQuestions(setup, config); + } catch (err: any) { + expect(err.message).to.equal("Unsupported command."); + } + }); + + it("should throw error when no services", async () => { + experimentsStub.returns(true); + loadAllStub.resolves([]); + + try { + await askQuestions(setup, config); + } catch (err: any) { + expect(err.message).to.equal( + `No Firebase Data Connect workspace found. Run ${clc.bold( + "firebase init dataconnect", + )} to set up a service and main schema.`, + ); + } + }); + + it("should skip service selection when exactly one service", async () => { + experimentsStub.returns(true); + loadAllStub.resolves([ + { + serviceName: "projects/project-id/locations/us-central1/services/service-id", + dataConnectYaml: { location: "us-central1", serviceId: "service-id" }, + }, + ]); + inputStub.onFirstCall().resolves("test_resolver"); + inputStub.onSecondCall().resolves("www.test.com"); + + await askQuestions(setup, config); + + expect(selectStub.called).to.be.false; + expect(inputStub.calledTwice).to.be.true; + expect(setup.featureInfo?.dataconnectResolver?.id).to.equal("test_resolver"); + expect(setup.featureInfo?.dataconnectResolver?.uri).to.equal("www.test.com"); + expect(setup.featureInfo?.dataconnectResolver?.serviceInfo.serviceName).to.equal( + "projects/project-id/locations/us-central1/services/service-id", + ); + }); + + it("should prompt for service selection when multiple services", async () => { + experimentsStub.returns(true); + loadAllStub.resolves([ + { serviceName: "projects/project-id/locations/us-central1/services/service-id" }, + { + serviceName: "projects/project-id/locations/us-central1/services/service-id2", + dataConnectYaml: { location: "us-central1", serviceId: "service-id2" }, + }, + ]); + selectStub.resolves({ + serviceName: "projects/project-id/locations/us-central1/services/service-id2", + dataConnectYaml: { location: "us-central1", serviceId: "service-id2" }, + }); + inputStub.onFirstCall().resolves("test_resolver"); + inputStub.onSecondCall().resolves("www.test.com"); + + await askQuestions(setup, config); + + expect(selectStub.calledOnce).to.be.true; + expect(inputStub.calledTwice).to.be.true; + expect(setup.featureInfo?.dataconnectResolver?.id).to.equal("test_resolver"); + expect(setup.featureInfo?.dataconnectResolver?.uri).to.equal("www.test.com"); + expect(setup.featureInfo?.dataconnectResolver?.serviceInfo.serviceName).to.equal( + "projects/project-id/locations/us-central1/services/service-id2", + ); + }); +}); + +describe("actuate", () => { + let setup: Setup; + let config: Config; + let experimentsStub: sinon.SinonStub; + let writeProjectFileStub: sinon.SinonStub; + let ensureSyncStub: sinon.SinonStub; + + beforeEach(() => { + experimentsStub = sinon.stub(experiments, "isEnabled"); + writeProjectFileStub = sinon.stub(); + ensureSyncStub = sinon.stub(fs, "ensureFileSync"); + + setup = { + config: { projectDir: "/path/to/project" } as any, + rcfile: {} as any, + featureInfo: { + dataconnectResolver: { + id: "test_resolver", + uri: "www.test.com", + serviceInfo: { + sourceDirectory: "/path/to/service", + serviceName: "service-id", + schemas: [], + dataConnectYaml: { + location: "us-central1", + serviceId: "service-id", + schemas: [ + { + source: "./schema", + datasource: {}, + }, + ], + connectorDirs: [], + }, + connectorInfo: [], + }, + }, + }, + instructions: [], + }; + config = { + writeProjectFile: writeProjectFileStub, + projectDir: "/path/to/project", + get: () => ({}), + set: () => ({}), + has: () => true, + path: (p: string) => p, + readProjectFile: () => ({}), + projectFileExists: () => true, + deleteProjectFile: () => ({}), + confirmWriteProjectFile: async () => true, + askWriteProjectFile: async () => ({}), + } as unknown as Config; + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should no-op when fdcwebhooks experiment is not enabled", async () => { + experimentsStub.returns(false); + + await actuate(setup, config); + + expect(writeProjectFileStub.called).to.be.false; + expect(ensureSyncStub.called).to.be.false; + }); + + it("should write dataconnect.yaml and set up empty secondary schema file", async () => { + experimentsStub.returns(true); + + await actuate(setup, config); + + expect(writeProjectFileStub.calledOnce).to.be.true; + const writtenYamlPath = writeProjectFileStub.getCall(0).args[0]; + const writtenYamlContents = writeProjectFileStub.getCall(0).args[1]; + const parsedYaml = yaml.load(writtenYamlContents); + expect(writtenYamlPath).to.equal("../service/dataconnect.yaml"); + expect(parsedYaml.schemas).to.have.lengthOf(2); + expect(ensureSyncStub.calledOnce).to.be.true; + const writtenSchemaPath = ensureSyncStub.getCall(0).args[0]; + expect(writtenSchemaPath).to.equal("/path/to/service/schema_test_resolver/schema.gql"); + }); +}); diff --git a/src/init/features/dataconnect/resolver.ts b/src/init/features/dataconnect/resolver.ts new file mode 100644 index 00000000000..15510721bdb --- /dev/null +++ b/src/init/features/dataconnect/resolver.ts @@ -0,0 +1,140 @@ +import * as clc from "colorette"; +import * as fs from "fs-extra"; +import { join, relative } from "path"; +import * as yaml from "yaml"; + +import { input, select } from "../../../prompt"; +import { Setup } from "../.."; +import { newUniqueId } from "../../../utils"; +import { Config } from "../../../config"; +import { FDC_DEFAULT_REGION } from "."; +import { loadAll } from "../../../dataconnect/load"; +import { DataConnectYaml, SchemaYaml, ServiceInfo } from "../../../dataconnect/types"; +import { parseServiceName } from "../../../dataconnect/names"; +import * as experiments from "../../../experiments"; +import { isBillingEnabled } from "../../../gcp/cloudbilling"; +import { trackGA4 } from "../../../track"; +import { Source } from "."; + +export interface ResolverRequiredInfo { + id: string; + uri: string; + serviceInfo: ServiceInfo; +} + +export async function askQuestions(setup: Setup, config: Config): Promise { + if (!experiments.isEnabled("fdcwebhooks")) { + throw new Error("Unsupported command."); + } + const resolverInfo: ResolverRequiredInfo = { + id: "", + uri: "", + serviceInfo: {} as ServiceInfo, + }; + + const serviceInfos = await loadAll(setup.projectId || "", config); + if (!serviceInfos.length) { + throw new Error( + `No Firebase Data Connect workspace found. Run ${clc.bold("firebase init dataconnect")} to set up a service and main schema.`, + ); + } else if (serviceInfos.length === 1) { + resolverInfo.serviceInfo = serviceInfos[0]; + } else { + const choices: Array<{ name: string; value: ServiceInfo }> = serviceInfos.map((si) => { + const serviceName = parseServiceName(si.serviceName); + return { + name: `${serviceName.location}/${serviceName.serviceId}`, + value: si, + }; + }); + resolverInfo.serviceInfo = await select({ + message: "Which service would you like to set up a custom resolver for?", + choices, + }); + } + + resolverInfo.id = await input({ + message: `What ID would you like to use for your custom resolver?`, + default: newUniqueId( + `resolver`, + resolverInfo.serviceInfo.dataConnectYaml.schemas?.map((sch) => sch.id || "") || [], + ), + }); + resolverInfo.uri = await input({ + message: `What is the URL of your Cloud Run data source that implements your custom resolver?`, + default: `https://${resolverInfo.id}-${setup.projectNumber || "PROJECT_NUMBER"}.${FDC_DEFAULT_REGION}.run.app/graphql`, + }); + + setup.featureInfo = setup.featureInfo || {}; + setup.featureInfo.dataconnectResolver = resolverInfo; +} + +export async function actuate(setup: Setup, config: Config) { + if (!experiments.isEnabled("fdcwebhooks")) { + return; + } + const resolverInfo = setup.featureInfo?.dataconnectResolver; + if (!resolverInfo) { + throw new Error("Data Connect resolver feature ResolverRequiredInfo not provided"); + } + const startTime = Date.now(); + try { + actuateWithInfo(config, resolverInfo); + } finally { + const source: Source = "init_resolver"; + void trackGA4( + "dataconnect_init", + { + source, + project_status: setup.projectId + ? (await isBillingEnabled(setup)) + ? "blaze" + : "spark" + : "missing", + ...{}, + }, + Date.now() - startTime, + ); + } +} + +function actuateWithInfo(config: Config, info: ResolverRequiredInfo) { + const dataConnectYaml = JSON.parse( + JSON.stringify(info.serviceInfo?.dataConnectYaml), + ) as DataConnectYaml; + addSchemaToDataConnectYaml(dataConnectYaml, info); + info.serviceInfo.dataConnectYaml = dataConnectYaml; + const dataConnectYamlContents = yaml.stringify(dataConnectYaml); + const dataConnectYamlPath = join(info.serviceInfo.sourceDirectory, "dataconnect.yaml"); + config.writeProjectFile( + relative(config.projectDir, dataConnectYamlPath), + dataConnectYamlContents, + ); + + // Write an empty schema.gql file. + fs.ensureFileSync(join(info.serviceInfo.sourceDirectory, `schema_${info.id}`, "schema.gql")); +} + +/** Add secondary schema configuration to dataconnect.yaml in place */ +export function addSchemaToDataConnectYaml( + dataConnectYaml: DataConnectYaml, + info: ResolverRequiredInfo, +): void { + const secondarySchema: SchemaYaml = { + source: `./schema_${info.id}`, + id: info.id, + datasource: { + httpGraphql: { + uri: info.uri, + }, + }, + }; + if (!dataConnectYaml.schemas) { + dataConnectYaml.schemas = []; + if (dataConnectYaml.schema) { + dataConnectYaml.schemas.push(dataConnectYaml.schema); + dataConnectYaml.schema = undefined; + } + } + dataConnectYaml.schemas.push(secondarySchema); +} diff --git a/src/init/features/index.ts b/src/init/features/index.ts index d4ecacaa2c1..4fdda3a1b52 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -37,6 +37,11 @@ export { SdkRequiredInfo as DataconnectSdkInfo, actuate as dataconnectSdkActuate, } from "./dataconnect/sdk"; +export { + askQuestions as dataconnectResolverAskQuestions, + ResolverRequiredInfo as DataconnectResolverInfo, + actuate as dataconnectResolverActuate, +} from "./dataconnect/resolver"; export { doSetup as apphosting } from "./apphosting"; export { doSetup as genkit } from "./genkit"; export { diff --git a/src/init/features/project.ts b/src/init/features/project.ts index 1e7b6527f57..b57e4c92b04 100644 --- a/src/init/features/project.ts +++ b/src/init/features/project.ts @@ -123,6 +123,7 @@ async function usingProjectMetadata( // write "default" alias and activate it immediately _.set(setup.rcfile, "projects.default", pm.projectId); setup.projectId = pm.projectId; + setup.projectNumber = pm.projectNumber; setup.instance = pm.resources?.realtimeDatabaseInstance; setup.projectLocation = pm.resources?.locationId; utils.makeActiveProject(config.projectDir, pm.projectId); diff --git a/src/init/index.ts b/src/init/index.ts index e3bfe50a2e3..eb6ab16716b 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -25,6 +25,7 @@ export interface Setup { /** Basic Project information */ project?: Record; projectId?: string; + projectNumber?: string; projectLocation?: string; isBillingEnabled?: boolean; @@ -36,6 +37,7 @@ export interface SetupInfo { firestore?: features.FirestoreInfo; dataconnect?: features.DataconnectInfo; dataconnectSdk?: features.DataconnectSdkInfo; + dataconnectResolver?: features.DataconnectResolverInfo; dataconnectSource?: features.DataconnectSource; storage?: features.StorageInfo; apptesting?: features.ApptestingInfo; @@ -80,6 +82,11 @@ const featuresList: Feature[] = [ askQuestions: features.dataconnectSdkAskQuestions, actuate: features.dataconnectSdkActuate, }, + { + name: "dataconnect:resolver", + askQuestions: features.dataconnectResolverAskQuestions, + actuate: features.dataconnectResolverActuate, + }, { name: "functions", doSetup: features.functions }, { name: "hosting",