Skip to content

Commit 0dc3fd7

Browse files
Implement CloudEvent to legacy event conversion (#283)
Implement cloudevent to legacy event conversion
1 parent edb0c71 commit 0dc3fd7

File tree

6 files changed

+421
-18
lines changed

6 files changed

+421
-18
lines changed

.github/workflows/conformance.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
with:
4646
functionType: 'legacyevent'
4747
useBuildpacks: false
48-
validateMapping: false
48+
validateMapping: true
4949
workingDirectory: 'test/conformance'
5050
cmd: "'npm start -- --target=writeLegacyEvent --signature-type=event'"
5151

src/invoker.ts

+2-7
Original file line numberDiff line numberDiff line change
@@ -195,14 +195,9 @@ export function wrapEventFunction(
195195
}
196196
}
197197
);
198-
let data = event.data;
198+
const data = event.data;
199199
let context = event.context;
200-
if (isBinaryCloudEvent(req)) {
201-
// Support CloudEvents in binary content mode, with data being the whole
202-
// request body and context attributes retrieved from request headers.
203-
data = event;
204-
context = getBinaryCloudEventContext(req);
205-
} else if (context === undefined) {
200+
if (context === undefined) {
206201
// Support legacy events and CloudEvents in structured content mode, with
207202
// context properties represented as event top-level properties.
208203
// Context is everything but data.

src/middelware/ce_to_legacy_event.ts

+192
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright 2019 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
import {Request, Response, NextFunction} from 'express';
15+
import {isBinaryCloudEvent, getBinaryCloudEventContext} from '../cloudevents';
16+
17+
const CE_TO_BACKGROUND_TYPE = new Map(
18+
Object.entries({
19+
'google.cloud.pubsub.topic.v1.messagePublished':
20+
'google.pubsub.topic.publish',
21+
'google.cloud.storage.object.v1.finalized':
22+
'google.storage.object.finalize',
23+
'google.cloud.storage.object.v1.deleted': 'google.storage.object.delete',
24+
'google.cloud.storage.object.v1.archived': 'google.storage.object.archive',
25+
'google.cloud.storage.object.v1.metadataUpdated':
26+
'google.storage.object.metadataUpdate',
27+
'google.cloud.firestore.document.v1.written':
28+
'providers/cloud.firestore/eventTypes/document.write',
29+
'google.cloud.firestore.document.v1.created':
30+
'providers/cloud.firestore/eventTypes/document.create',
31+
'google.cloud.firestore.document.v1.updated':
32+
'providers/cloud.firestore/eventTypes/document.update',
33+
'google.cloud.firestore.document.v1.deleted':
34+
'providers/cloud.firestore/eventTypes/document.delete',
35+
'google.firebase.auth.user.v1.created':
36+
'providers/firebase.auth/eventTypes/user.create',
37+
'google.firebase.auth.user.v1.deleted':
38+
'providers/firebase.auth/eventTypes/user.delete',
39+
'google.firebase.analytics.log.v1.written':
40+
'providers/google.firebase.analytics/eventTypes/event.log',
41+
'google.firebase.database.document.v1.created':
42+
'providers/google.firebase.database/eventTypes/ref.create',
43+
'google.firebase.database.document.v1.written':
44+
'providers/google.firebase.database/eventTypes/ref.write',
45+
'google.firebase.database.document.v1.updated':
46+
'providers/google.firebase.database/eventTypes/ref.update',
47+
'google.firebase.database.document.v1.deleted':
48+
'providers/google.firebase.database/eventTypes/ref.delete',
49+
})
50+
);
51+
52+
// CloudEvent service names.
53+
const FIREBASE_AUTH_CE_SERVICE = 'firebaseauth.googleapis.com';
54+
const PUBSUB_CE_SERVICE = 'pubsub.googleapis.com';
55+
const STORAGE_CE_SERVICE = 'storage.googleapis.com';
56+
57+
const PUBSUB_MESSAGE_TYPE =
58+
'type.googleapis.com/google.pubsub.v1.PubsubMessage';
59+
60+
/**
61+
* Regex to split a CE source string into service and name components.
62+
*/
63+
const CE_SOURCE_REGEX = /\/\/([^/]+)\/(.+)/;
64+
65+
/**
66+
* Costom exception class to represent errors durring event converion.
67+
*/
68+
export class EventConversionError extends Error {}
69+
70+
/**
71+
* Is the given request a known CloudEvent that can be converted to a legacy event.
72+
* @param request express request object
73+
* @returns true if the request can be converted
74+
*/
75+
const isConvertableCloudEvent = (request: Request): boolean => {
76+
if (isBinaryCloudEvent(request)) {
77+
const ceType = request.header('ce-type');
78+
return CE_TO_BACKGROUND_TYPE.has(ceType!);
79+
}
80+
return false;
81+
};
82+
83+
/**
84+
* Splits a CloudEvent source string into resource and subject components.
85+
* @param source the cloud event source
86+
* @returns the parsed service and name components of the CE source string
87+
*/
88+
export const parseSource = (
89+
source: string
90+
): {service: string; name: string} => {
91+
const match = source.match(CE_SOURCE_REGEX);
92+
if (!match) {
93+
throw new EventConversionError(
94+
`Failed to convert CloudEvent with invalid source: "${source}"`
95+
);
96+
}
97+
return {
98+
service: match![1],
99+
name: match![2],
100+
};
101+
};
102+
103+
/**
104+
* Marshal a known GCP CloudEvent request the equivalent context/data legacy event format.
105+
* @param req express request object
106+
* @returns the request body of the equivalent legacy event request
107+
*/
108+
const marshallConvertableCloudEvent = (
109+
req: Request
110+
): {context: object; data: object} => {
111+
const ceContext = getBinaryCloudEventContext(req);
112+
const {service, name} = parseSource(ceContext.source!);
113+
const subject = ceContext.subject!;
114+
let data = req.body;
115+
116+
// The default resource is a string made up of the source name and subject.
117+
let resource: string | {[key: string]: string} = `${name}/${subject}`;
118+
119+
switch (service) {
120+
case PUBSUB_CE_SERVICE:
121+
// PubSub resource format
122+
resource = {
123+
service: service,
124+
name: name,
125+
type: PUBSUB_MESSAGE_TYPE,
126+
};
127+
// If the data payload has a "message", it needs to be flattened
128+
if ('message' in data) {
129+
data = data.message;
130+
}
131+
break;
132+
case FIREBASE_AUTH_CE_SERVICE:
133+
// FirebaseAuth resource format
134+
resource = name;
135+
if ('metadata' in data) {
136+
// Some metadata are not consistent between cloudevents and legacy events
137+
const metadata: object = data.metadata;
138+
data.metadata = {};
139+
// eslint-disable-next-line prefer-const
140+
for (let [k, v] of Object.entries(metadata)) {
141+
k = k === 'createTime' ? 'createdAt' : k;
142+
k = k === 'lastSignInTime' ? 'lastSignedInAt' : k;
143+
data.metadata[k] = v;
144+
}
145+
}
146+
break;
147+
case STORAGE_CE_SERVICE:
148+
// CloudStorage resource format
149+
resource = {
150+
name: `${name}/${subject}`,
151+
service: service,
152+
type: data.kind,
153+
};
154+
break;
155+
}
156+
157+
return {
158+
context: {
159+
eventId: ceContext.id!,
160+
timestamp: ceContext.time!,
161+
eventType: CE_TO_BACKGROUND_TYPE.get(ceContext.type!),
162+
resource,
163+
},
164+
data,
165+
};
166+
};
167+
168+
/**
169+
* Express middleware to convert cloud event requests to legacy GCF events. This enables
170+
* functions using the "EVENT" signature type to accept requests from a cloud event producer.
171+
* @param req express request object
172+
* @param res express response object
173+
* @param next function used to pass control to the next middle middleware function in the stack
174+
*/
175+
export const ceToLegacyEventMiddleware = (
176+
req: Request,
177+
res: Response,
178+
next: NextFunction
179+
) => {
180+
if (isConvertableCloudEvent(req)) {
181+
// This is a CloudEvent that can be converted a known legacy event.
182+
req.body = marshallConvertableCloudEvent(req);
183+
} else if (isBinaryCloudEvent(req)) {
184+
// Support CloudEvents in binary content mode, with data being the whole
185+
// request body and context attributes retrieved from request headers.
186+
req.body = {
187+
context: getBinaryCloudEventContext(req),
188+
data: req.body,
189+
};
190+
}
191+
next();
192+
};

src/server.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {SignatureType} from './types';
2020
import {setLatestRes} from './invoker';
2121
import {registerFunctionRoutes} from './router';
2222
import {legacyPubSubEventMiddleware} from './pubsub_middleware';
23+
import {ceToLegacyEventMiddleware} from './middelware/ce_to_legacy_event';
2324

2425
/**
2526
* Creates and configures an Express application and returns an HTTP server
@@ -98,11 +99,12 @@ export function getServer(
9899
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
99100
app.disable('x-powered-by');
100101

101-
// If a Pub/Sub subscription is configured to invoke a user's function directly, the request body
102-
// needs to be marshalled into the structure that wrapEventFunction expects. This unblocks local
103-
// development with the Pub/Sub emulator
104102
if (functionSignatureType === SignatureType.EVENT) {
103+
// If a Pub/Sub subscription is configured to invoke a user's function directly, the request body
104+
// needs to be marshalled into the structure that wrapEventFunction expects. This unblocks local
105+
// development with the Pub/Sub emulator
105106
app.use(legacyPubSubEventMiddleware);
107+
app.use(ceToLegacyEventMiddleware);
106108
}
107109

108110
registerFunctionRoutes(app, userFunction, functionSignatureType);

test/integration/legacy_event.ts

+39-7
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ describe('Event Function', () => {
4545
},
4646
data: {some: 'payload'},
4747
},
48-
expectedResource: {
48+
expectedData: {some: 'payload'},
49+
expectedContext: {
4950
eventId: 'testEventId',
5051
eventType: 'testEventType',
5152
resource: 'testResource',
@@ -62,7 +63,8 @@ describe('Event Function', () => {
6263
resource: 'testResource',
6364
data: {some: 'payload'},
6465
},
65-
expectedResource: {
66+
expectedData: {some: 'payload'},
67+
expectedContext: {
6668
eventId: 'testEventId',
6769
eventType: 'testEventType',
6870
resource: 'testResource',
@@ -85,7 +87,8 @@ describe('Event Function', () => {
8587
},
8688
data: {some: 'payload'},
8789
},
88-
expectedResource: {
90+
expectedData: {some: 'payload'},
91+
expectedContext: {
8992
eventId: 'testEventId',
9093
eventType: 'testEventType',
9194
resource: {
@@ -100,7 +103,8 @@ describe('Event Function', () => {
100103
name: 'CloudEvents v1.0 structured content request',
101104
headers: {'Content-Type': 'application/cloudevents+json'},
102105
body: TEST_CLOUD_EVENT,
103-
expectedResource: {
106+
expectedData: {some: 'payload'},
107+
expectedContext: {
104108
datacontenttype: 'application/json',
105109
id: 'test-1234-1234',
106110
source:
@@ -124,7 +128,8 @@ describe('Event Function', () => {
124128
'ce-datacontenttype': TEST_CLOUD_EVENT.datacontenttype,
125129
},
126130
body: TEST_CLOUD_EVENT.data,
127-
expectedResource: {
131+
expectedData: TEST_CLOUD_EVENT.data,
132+
expectedContext: {
128133
datacontenttype: 'application/json',
129134
id: 'test-1234-1234',
130135
source:
@@ -135,6 +140,33 @@ describe('Event Function', () => {
135140
type: 'com.google.cloud.storage',
136141
},
137142
},
143+
{
144+
name: 'Firebase Database CloudEvent',
145+
headers: {
146+
'ce-specversion': '1.0',
147+
'ce-type': 'google.firebase.database.document.v1.written',
148+
'ce-source':
149+
'//firebasedatabase.googleapis.com/projects/_/instances/my-project-id',
150+
'ce-subject': 'refs/gcf-test/xyz',
151+
'ce-id': 'aaaaaa-1111-bbbb-2222-cccccccccccc',
152+
'ce-time': '2020-09-29T11:32:00.000Z',
153+
'ce-datacontenttype': 'application/json',
154+
},
155+
body: {
156+
data: null,
157+
delta: 10,
158+
},
159+
expectedData: {
160+
data: null,
161+
delta: 10,
162+
},
163+
expectedContext: {
164+
resource: 'projects/_/instances/my-project-id/refs/gcf-test/xyz',
165+
timestamp: '2020-09-29T11:32:00.000Z',
166+
eventType: 'providers/google.firebase.database/eventTypes/ref.write',
167+
eventId: 'aaaaaa-1111-bbbb-2222-cccccccccccc',
168+
},
169+
},
138170
];
139171
testData.forEach(test => {
140172
it(test.name, async () => {
@@ -153,8 +185,8 @@ describe('Event Function', () => {
153185
.send(test.body)
154186
.set(requestHeaders)
155187
.expect(204);
156-
assert.deepStrictEqual(receivedData, {some: 'payload'});
157-
assert.deepStrictEqual(receivedContext, test.expectedResource);
188+
assert.deepStrictEqual(receivedData, test.expectedData);
189+
assert.deepStrictEqual(receivedContext, test.expectedContext);
158190
});
159191
});
160192
});

0 commit comments

Comments
 (0)