Skip to content

Commit f98ffe4

Browse files
authored
Allow a Callable to include a Genkit Action annotation (#8039)
In preparation for Genkit 1.0, we are going to have a `onCallGenkit` function declaration in `firebase-functions/https`. This will be a "subclass" of callable functions. To represent this, "Genkit callables" are represented in memory as a callableTriggered with a new "genkitAction" property. Actual Cloud Functions will be created with the label deployed-callable: true still, but will also include a 'genkit-action' label as well, which will eventually be used to annotate the Firebase Console. To allow users to migrate from `@genkit-ai/firebase/functions:onFlow` to `firebase-functions/https:onCallGenkit` we need to relax the restrictions around converting function types in function updates. This change allows an HTTPS function to become a Callable function. I also noticed a bug where you could convert between any type of non-auth-context Firestore function to any type of auth-context Firestore function and fixed it.
1 parent bc1a390 commit f98ffe4

File tree

11 files changed

+130
-20
lines changed

11 files changed

+130
-20
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
- Changes default CF3 runtime to nodejs22 (#8037)
22
- Fixed an issue where `--import` would error for the Data Connect emulator if `dataDir` was also set.
33
- Fixed an issue where `firebase init dataconnect` errored when importing a schema with no GQL files.
4+
- CF3 callables can now be annotate with a genkit action they are serving (#8039)
5+
- HTTPS functions can now be upgraded to HTTPS Callable functions (#8039)
6+
- Update default tsconfig to support more modern defaults (#8039)

src/deploy/functions/backend.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as utils from "../../utils";
44
import { Runtime } from "./runtimes/supported";
55
import { FirebaseError } from "../../error";
66
import { Context } from "./args";
7-
import { flattenArray } from "../../functional";
7+
import { assertExhaustive, flattenArray } from "../../functional";
88

99
/** Retry settings for a ScheduleSpec. */
1010
export interface ScheduleRetryConfig {
@@ -41,7 +41,9 @@ export interface HttpsTriggered {
4141
}
4242

4343
/** API agnostic version of a Firebase callable function. */
44-
export type CallableTrigger = Record<string, never>;
44+
export type CallableTrigger = {
45+
genkitAction?: string;
46+
};
4547

4648
/** Something that has a callable trigger */
4749
export interface CallableTriggered {
@@ -135,6 +137,7 @@ export interface BlockingTrigger {
135137
eventType: string;
136138
options?: Record<string, unknown>;
137139
}
140+
138141
export interface BlockingTriggered {
139142
blockingTrigger: BlockingTrigger;
140143
}
@@ -153,9 +156,8 @@ export function endpointTriggerType(endpoint: Endpoint): string {
153156
return "taskQueue";
154157
} else if (isBlockingTriggered(endpoint)) {
155158
return endpoint.blockingTrigger.eventType;
156-
} else {
157-
throw new Error("Unexpected trigger type for endpoint " + JSON.stringify(endpoint));
158159
}
160+
assertExhaustive(endpoint);
159161
}
160162

161163
// TODO(inlined): Enum types should be singularly named

src/deploy/functions/build.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,9 @@ export interface HttpsTrigger {
7474

7575
// Trigger definitions for RPCs servers using the HTTP protocol defined at
7676
// https://firebase.google.com/docs/functions/callable-reference
77-
// eslint-disable-next-line
78-
interface CallableTrigger {}
77+
interface CallableTrigger {
78+
genkitAction?: string;
79+
}
7980

8081
// Trigger definitions for endpoints that should be called as a delegate for other operations.
8182
// For example, before user login.
@@ -568,7 +569,9 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe
568569
}
569570
return { httpsTrigger };
570571
} else if (isCallableTriggered(endpoint)) {
571-
return { callableTrigger: {} };
572+
const trigger: CallableTriggered = { callableTrigger: {} };
573+
proto.copyIfPresent(trigger.callableTrigger, endpoint.callableTrigger, "genkitAction");
574+
return trigger;
572575
} else if (isBlockingTriggered(endpoint)) {
573576
return { blockingTrigger: endpoint.blockingTrigger };
574577
} else if (isEventTriggered(endpoint)) {

src/deploy/functions/release/planner.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ describe("planner", () => {
4646
expect(() => planner.calculateUpdate(httpsFunc, scheduleFunc)).to.throw();
4747
});
4848

49+
it("allows upgrades of genkit functions from the genkit plugin to firebase-functions SDK", () => {
50+
const httpsFunc = func("a", "b", { httpsTrigger: {} });
51+
const genkitFunc = func("a", "b", { callableTrigger: { genkitAction: "flows/flow" } });
52+
expect(planner.calculateUpdate(genkitFunc, httpsFunc)).to.deep.equal({
53+
// Missing: deleteAndRecreate
54+
endpoint: genkitFunc,
55+
unsafe: false,
56+
});
57+
});
58+
4959
it("knows to delete & recreate for v2 topic changes", () => {
5060
const original: backend.Endpoint = {
5161
...func("a", "b", {

src/deploy/functions/release/planner.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@ import { FirebaseError } from "../../../error";
88
import * as utils from "../../../utils";
99
import * as backend from "../backend";
1010
import * as v2events from "../../../functions/events/v2";
11-
import {
12-
FIRESTORE_EVENT_REGEX,
13-
FIRESTORE_EVENT_WITH_AUTH_CONTEXT_REGEX,
14-
} from "../../../functions/events/v2";
1511

1612
export interface EndpointUpdate {
1713
endpoint: backend.Endpoint;
@@ -261,9 +257,9 @@ export function upgradedScheduleFromV1ToV2(
261257
export function checkForUnsafeUpdate(want: backend.Endpoint, have: backend.Endpoint): boolean {
262258
return (
263259
backend.isEventTriggered(want) &&
264-
FIRESTORE_EVENT_WITH_AUTH_CONTEXT_REGEX.test(want.eventTrigger.eventType) &&
265260
backend.isEventTriggered(have) &&
266-
FIRESTORE_EVENT_REGEX.test(have.eventTrigger.eventType)
261+
want.eventTrigger.eventType ===
262+
v2events.CONVERTABLE_EVENTS[have.eventTrigger.eventType as v2events.Event]
267263
);
268264
}
269265

@@ -289,7 +285,12 @@ export function checkForIllegalUpdate(want: backend.Endpoint, have: backend.Endp
289285
};
290286
const wantType = triggerType(want);
291287
const haveType = triggerType(have);
292-
if (wantType !== haveType) {
288+
289+
// Originally, @genkit-ai/firebase/functions defined onFlow which created an HTTPS trigger that implemented the streaming callable protocol for the Flow.
290+
// The new version is firebase-functions/https which defines onCallFlow
291+
const upgradingHttpsFunction =
292+
backend.isHttpsTriggered(have) && backend.isCallableTriggered(want);
293+
if (wantType !== haveType && !upgradingHttpsFunction) {
293294
throw new FirebaseError(
294295
`[${getFunctionLabel(
295296
want,

src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,35 @@ describe("buildFromV1Alpha", () => {
162162
});
163163
});
164164

165+
describe("genkitTriggers", () => {
166+
it("fails with invalid fields", () => {
167+
assertParserError({
168+
endpoints: {
169+
func: {
170+
...MIN_ENDPOINT,
171+
genkitTrigger: {
172+
tool: "tools are not supported",
173+
},
174+
},
175+
},
176+
});
177+
});
178+
179+
it("cannot be used with 1st gen", () => {
180+
assertParserError({
181+
endpoints: {
182+
func: {
183+
...MIN_ENDPOINT,
184+
platform: "gcfv1",
185+
genkitTrigger: {
186+
flow: "agent",
187+
},
188+
},
189+
},
190+
});
191+
});
192+
});
193+
165194
describe("scheduleTriggers", () => {
166195
const validTrigger: build.ScheduleTrigger = {
167196
schedule: "every 5 minutes",

src/deploy/functions/runtimes/discovery/v1alpha1.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ function assertBuildEndpoint(ep: WireEndpoint, id: string): void {
214214
invoker: "array?",
215215
});
216216
} else if (build.isCallableTriggered(ep)) {
217-
// no-op
217+
assertKeyTypes(prefix + ".callableTrigger", ep.callableTrigger, {
218+
genkitAction: "string?",
219+
});
218220
} else if (build.isScheduleTriggered(ep)) {
219221
assertKeyTypes(prefix + ".scheduleTrigger", ep.scheduleTrigger, {
220222
schedule: "Field<string>",
@@ -263,6 +265,7 @@ function assertBuildEndpoint(ep: WireEndpoint, id: string): void {
263265
options: "object",
264266
});
265267
} else {
268+
// TODO: Replace with assertExhaustive, which needs some type magic here because we have an any
266269
throw new FirebaseError(
267270
`Do not recognize trigger type for endpoint ${id}. Try upgrading ` +
268271
"firebase-tools with npm install -g firebase-tools@latest",
@@ -310,6 +313,7 @@ function parseEndpointForBuild(
310313
copyIfPresent(triggered.httpsTrigger, ep.httpsTrigger, "invoker");
311314
} else if (build.isCallableTriggered(ep)) {
312315
triggered = { callableTrigger: {} };
316+
copyIfPresent(triggered.callableTrigger, ep.callableTrigger, "genkitAction");
313317
} else if (build.isScheduleTriggered(ep)) {
314318
const st: build.ScheduleTrigger = {
315319
// TODO: consider adding validation for fields like this that reject

src/functions/events/v2.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,6 @@ export const FIRESTORE_EVENTS = [
3333

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

36-
export const FIRESTORE_EVENT_REGEX = /^google\.cloud\.firestore\.document\.v1\.[^\.]*$/;
37-
export const FIRESTORE_EVENT_WITH_AUTH_CONTEXT_REGEX =
38-
/^google\.cloud\.firestore\.document\.v1\..*\.withAuthContext$/;
39-
4036
export type Event =
4137
| typeof PUBSUB_PUBLISH_EVENT
4238
| (typeof STORAGE_EVENTS)[number]
@@ -46,3 +42,17 @@ export type Event =
4642
| typeof TEST_LAB_EVENT
4743
| (typeof FIRESTORE_EVENTS)[number]
4844
| typeof FIREALERTS_EVENT;
45+
46+
// Why can't auth context be removed? This is map was added to correct a bug where a regex
47+
// allowed any non-auth type to be converted to any auth type, but we should follow up for why
48+
// a functon can't opt into reducing PII.
49+
export const CONVERTABLE_EVENTS: Partial<Record<Event, Event>> = {
50+
"google.cloud.firestore.document.v1.created":
51+
"google.cloud.firestore.document.v1.created.withAuthContext",
52+
"google.cloud.firestore.document.v1.updated":
53+
"google.cloud.firestore.document.v1.updated.withAuthContext",
54+
"google.cloud.firestore.document.v1.deleted":
55+
"google.cloud.firestore.document.v1.deleted.withAuthContext",
56+
"google.cloud.firestore.document.v1.written":
57+
"google.cloud.firestore.document.v1.written.withAuthContext",
58+
};

src/gcp/cloudfunctionsv2.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,23 @@ describe("cloudfunctionsv2", () => {
221221
[BLOCKING_LABEL]: "before-sign-in",
222222
},
223223
});
224+
225+
expect(
226+
cloudfunctionsv2.functionFromEndpoint({
227+
...ENDPOINT,
228+
platform: "gcfv2",
229+
callableTrigger: {
230+
genkitAction: "flows/flow",
231+
},
232+
}),
233+
).to.deep.equal({
234+
...CLOUD_FUNCTION_V2,
235+
labels: {
236+
...CLOUD_FUNCTION_V2.labels,
237+
"deployment-callable": "true",
238+
"genkit-action": "flows/flow",
239+
},
240+
});
224241
});
225242

226243
it("should copy trival fields", () => {
@@ -637,6 +654,29 @@ describe("cloudfunctionsv2", () => {
637654
});
638655
});
639656

657+
it("should translate genkit callables", () => {
658+
expect(
659+
cloudfunctionsv2.endpointFromFunction({
660+
...HAVE_CLOUD_FUNCTION_V2,
661+
labels: {
662+
"deployment-callable": "true",
663+
"genkit-action": "flows/flow",
664+
},
665+
}),
666+
).to.deep.equal({
667+
...ENDPOINT,
668+
callableTrigger: {
669+
genkitAction: "flows/flow",
670+
},
671+
platform: "gcfv2",
672+
uri: GCF_URL,
673+
labels: {
674+
"deployment-callable": "true",
675+
"genkit-action": "flows/flow",
676+
},
677+
});
678+
});
679+
640680
it("should copy optional fields", () => {
641681
const extraFields: backend.ServiceConfiguration = {
642682
ingressSettings: "ALLOW_ALL",

src/gcp/cloudfunctionsv2.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,9 @@ export function functionFromEndpoint(endpoint: backend.Endpoint): InputCloudFunc
609609
gcfFunction.labels = { ...gcfFunction.labels, "deployment-taskqueue": "true" };
610610
} else if (backend.isCallableTriggered(endpoint)) {
611611
gcfFunction.labels = { ...gcfFunction.labels, "deployment-callable": "true" };
612+
if (endpoint.callableTrigger.genkitAction) {
613+
gcfFunction.labels["genkit-action"] = endpoint.callableTrigger.genkitAction;
614+
}
612615
} else if (backend.isBlockingTriggered(endpoint)) {
613616
gcfFunction.labels = {
614617
...gcfFunction.labels,
@@ -654,6 +657,9 @@ export function endpointFromFunction(gcfFunction: OutputCloudFunction): backend.
654657
trigger = {
655658
callableTrigger: {},
656659
};
660+
if (gcfFunction.labels["genkit-action"]) {
661+
trigger.callableTrigger.genkitAction = gcfFunction.labels["genkit-action"];
662+
}
657663
} else if (gcfFunction.labels?.[BLOCKING_LABEL]) {
658664
trigger = {
659665
blockingTrigger: {

0 commit comments

Comments
 (0)