Skip to content

Commit 81075d5

Browse files
committed
Add v1 compat helper and integrate with Pub/Sub
1 parent f2099ec commit 81075d5

File tree

4 files changed

+331
-15
lines changed

4 files changed

+331
-15
lines changed

spec/v2/compat.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { expect } from "chai";
2+
3+
import { decorateLegacyEvent, makeLegacyEventContext } from "../../src/v2/compat";
4+
5+
describe("compat", () => {
6+
describe("decorateLegacyEvent", () => {
7+
it("should attach a lazy context getter and memoize the result", () => {
8+
const event = {
9+
id: "abc",
10+
time: "2024-01-01T00:00:00.000Z",
11+
source: "//service/resource/foo",
12+
} as any;
13+
14+
decorateLegacyEvent(event, {
15+
context: {
16+
eventType: "test.event",
17+
service: "service.googleapis.com",
18+
params: { foo: "bar" },
19+
},
20+
});
21+
22+
const context1 = event.context;
23+
const context2 = event.context;
24+
expect(context1).to.equal(context2);
25+
expect(context1).to.deep.equal(
26+
makeLegacyEventContext(event, {
27+
eventType: "test.event",
28+
service: "service.googleapis.com",
29+
params: { foo: "bar" },
30+
})
31+
);
32+
});
33+
34+
it("should attach additional lazy getters", () => {
35+
const event = {
36+
id: "abc",
37+
} as any;
38+
let computeCount = 0;
39+
40+
decorateLegacyEvent(event, {
41+
context: {
42+
eventType: "test.event",
43+
service: "service.googleapis.com",
44+
},
45+
getters: {
46+
value: () => {
47+
computeCount++;
48+
return "computed";
49+
},
50+
},
51+
});
52+
53+
expect(event.value).to.equal("computed");
54+
expect(event.value).to.equal("computed");
55+
expect(computeCount).to.equal(1);
56+
});
57+
});
58+
});

spec/v2/providers/pubsub.spec.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,34 +163,35 @@ describe("onMessagePublished", () => {
163163

164164
const eventAgain = await func(event);
165165

166-
// Deep equal uses JSON equality, so we'll still match even though
167-
// Message is a class and we passed an interface.
168-
expect(eventAgain).to.deep.equal(event);
169-
170166
expect(json).to.deep.equal({ hello: "world" });
167+
168+
expect(eventAgain.data.message).to.be.instanceOf(pubsub.Message);
169+
expect(eventAgain.context.eventType).to.equal("google.pubsub.topic.publish");
170+
expect(eventAgain.context.resource.name).to.equal("projects/aProject/topics/topic");
171+
expect(eventAgain.message.data).to.equal(messageJSON.data);
171172
});
172173

173174
// These tests pass if the transpiler works
174175
it("allows desirable syntax", () => {
175176
pubsub.onMessagePublished<string>(
176177
"topic",
177178
// eslint-disable-next-line @typescript-eslint/no-unused-vars
178-
(event: CloudEvent<pubsub.MessagePublishedData<string>>) => undefined
179+
(event: pubsub.MessagePublishedEvent<string>) => undefined
179180
);
180181
pubsub.onMessagePublished<string>(
181182
"topic",
182183
// eslint-disable-next-line @typescript-eslint/no-unused-vars
183-
(event: CloudEvent<pubsub.MessagePublishedData>) => undefined
184+
(event: pubsub.MessagePublishedEvent) => undefined
184185
);
185186
pubsub.onMessagePublished(
186187
"topic",
187188
// eslint-disable-next-line @typescript-eslint/no-unused-vars
188-
(event: CloudEvent<pubsub.MessagePublishedData<string>>) => undefined
189+
(event: pubsub.MessagePublishedEvent<string>) => undefined
189190
);
190191
pubsub.onMessagePublished(
191192
"topic",
192193
// eslint-disable-next-line @typescript-eslint/no-unused-vars
193-
(event: CloudEvent<pubsub.MessagePublishedData>) => undefined
194+
(event: pubsub.MessagePublishedEvent) => undefined
194195
);
195196
});
196197
});

src/v2/compat.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// The MIT License (MIT)
2+
//
3+
// Copyright (c) 2025 Firebase
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
import { EventContext, Resource } from "../v1/cloud-functions";
24+
25+
export interface LegacyContextOptions<Params> {
26+
/**
27+
* The event type to expose via the legacy context.
28+
*/
29+
eventType: string;
30+
/**
31+
* The backing service name (e.g. firestore.googleapis.com).
32+
*/
33+
service: string;
34+
/**
35+
* Optional resource information. If omitted the function will attempt to
36+
* derive a resource from the CloudEvent source.
37+
*/
38+
resource?: string | Resource;
39+
/**
40+
* Event params to expose.
41+
*/
42+
params?: Params;
43+
/**
44+
* Optional override for the legacy timestamp. Defaults to the CloudEvent time.
45+
*/
46+
timestamp?: string;
47+
/**
48+
* Optional override for the legacy event ID. Defaults to the CloudEvent id.
49+
*/
50+
eventId?: string;
51+
/**
52+
* Optional auth information to surface.
53+
*/
54+
auth?: EventContext<Params>["auth"];
55+
/**
56+
* Optional auth type information to surface.
57+
*/
58+
authType?: EventContext<Params>["authType"];
59+
}
60+
61+
/**
62+
* Defines a lazily-evaluated property on an object with memoized computation.
63+
*
64+
* @internal
65+
*/
66+
export function defineLazyGetter<T extends object, K extends PropertyKey, V>(
67+
target: T,
68+
property: K,
69+
compute: () => V
70+
): void {
71+
if (Object.prototype.hasOwnProperty.call(target, property)) {
72+
return;
73+
}
74+
75+
let cached: V;
76+
let initialized = false;
77+
78+
Object.defineProperty(target, property, {
79+
configurable: true,
80+
enumerable: true,
81+
get(): V {
82+
if (!initialized) {
83+
cached = compute();
84+
initialized = true;
85+
}
86+
return cached;
87+
},
88+
});
89+
}
90+
91+
/**
92+
* Creates a v1-compatible EventContext from a v2 CloudEvent.
93+
*
94+
* @param event - The CloudEvent carrying the id/time/source metadata.
95+
* @param options - Legacy context configuration.
96+
* @internal
97+
*/
98+
export function makeLegacyEventContext<Params>(
99+
event: { id?: string; time?: string; source?: string },
100+
options: LegacyContextOptions<Params>
101+
): EventContext<Params> {
102+
const resource = normalizeResource(options.service, options.resource ?? event.source);
103+
return {
104+
eventId: options.eventId ?? event.id ?? "",
105+
timestamp: options.timestamp ?? event.time ?? new Date().toISOString(),
106+
eventType: options.eventType,
107+
resource,
108+
params: (options.params ?? ({} as Params)) as Params,
109+
auth: options.auth,
110+
authType: options.authType,
111+
};
112+
}
113+
114+
/**
115+
* A bundle describing how to decorate an event with legacy helpers.
116+
*/
117+
export interface LegacyDecoration<Params, Extras extends Record<string, () => unknown>> {
118+
context: LegacyContextOptions<Params>;
119+
getters?: Extras;
120+
}
121+
122+
/**
123+
* Applies legacy context plus any number of additional lazy getters to the given event object.
124+
*
125+
* @internal
126+
*/
127+
export function decorateLegacyEvent<
128+
EventType extends object,
129+
Params,
130+
Extras extends Record<string, () => unknown>
131+
>(event: EventType, decoration: LegacyDecoration<Params, Extras>): void {
132+
defineLazyGetter(
133+
event as EventType & { context?: EventContext<Params> },
134+
"context",
135+
() => makeLegacyEventContext(event as any, decoration.context)
136+
);
137+
138+
if (!decoration.getters) {
139+
return;
140+
}
141+
142+
for (const [key, factory] of Object.entries(decoration.getters)) {
143+
defineLazyGetter(event, key as keyof EventType, factory as () => unknown);
144+
}
145+
}
146+
147+
function normalizeResource(service: string, resource?: string | Resource): Resource {
148+
if (!resource) {
149+
return {
150+
service,
151+
name: "",
152+
};
153+
}
154+
155+
if (typeof resource !== "string") {
156+
return {
157+
service: resource.service ?? service,
158+
name: resource.name,
159+
type: resource.type,
160+
labels: resource.labels,
161+
};
162+
}
163+
164+
let computedService = service;
165+
let name = resource.trim();
166+
167+
if (name.startsWith("//")) {
168+
const withoutScheme = name.slice(2);
169+
const firstSlash = withoutScheme.indexOf("/");
170+
if (firstSlash === -1) {
171+
computedService = withoutScheme || service;
172+
name = "";
173+
} else {
174+
computedService = withoutScheme.slice(0, firstSlash) || service;
175+
name = withoutScheme.slice(firstSlash + 1);
176+
}
177+
}
178+
179+
if (name.startsWith(computedService + "/")) {
180+
name = name.slice(computedService.length + 1);
181+
}
182+
183+
name = name.replace(/^\/+/, "");
184+
185+
return {
186+
service: computedService,
187+
name,
188+
};
189+
}

0 commit comments

Comments
 (0)