Skip to content

Implement CloudEvent to legacy event conversion #283

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
with:
functionType: 'legacyevent'
useBuildpacks: false
validateMapping: false
validateMapping: true
workingDirectory: 'test/conformance'
cmd: "'npm start -- --target=writeLegacyEvent --signature-type=event'"

Expand Down
9 changes: 2 additions & 7 deletions src/invoker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,9 @@ export function wrapEventFunction(
}
}
);
let data = event.data;
const data = event.data;
let context = event.context;
if (isBinaryCloudEvent(req)) {
// Support CloudEvents in binary content mode, with data being the whole
// request body and context attributes retrieved from request headers.
data = event;
context = getBinaryCloudEventContext(req);
} else if (context === undefined) {
if (context === undefined) {
// Support legacy events and CloudEvents in structured content mode, with
// context properties represented as event top-level properties.
// Context is everything but data.
Expand Down
192 changes: 192 additions & 0 deletions src/middelware/ce_to_legacy_event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {Request, Response, NextFunction} from 'express';
import {isBinaryCloudEvent, getBinaryCloudEventContext} from '../cloudevents';

const CE_TO_BACKGROUND_TYPE = new Map(
Object.entries({
'google.cloud.pubsub.topic.v1.messagePublished':
'google.pubsub.topic.publish',
'google.cloud.storage.object.v1.finalized':
'google.storage.object.finalize',
'google.cloud.storage.object.v1.deleted': 'google.storage.object.delete',
'google.cloud.storage.object.v1.archived': 'google.storage.object.archive',
'google.cloud.storage.object.v1.metadataUpdated':
'google.storage.object.metadataUpdate',
'google.cloud.firestore.document.v1.written':
'providers/cloud.firestore/eventTypes/document.write',
'google.cloud.firestore.document.v1.created':
'providers/cloud.firestore/eventTypes/document.create',
'google.cloud.firestore.document.v1.updated':
'providers/cloud.firestore/eventTypes/document.update',
'google.cloud.firestore.document.v1.deleted':
'providers/cloud.firestore/eventTypes/document.delete',
'google.firebase.auth.user.v1.created':
'providers/firebase.auth/eventTypes/user.create',
'google.firebase.auth.user.v1.deleted':
'providers/firebase.auth/eventTypes/user.delete',
'google.firebase.analytics.log.v1.written':
'providers/google.firebase.analytics/eventTypes/event.log',
'google.firebase.database.document.v1.created':
'providers/google.firebase.database/eventTypes/ref.create',
'google.firebase.database.document.v1.written':
'providers/google.firebase.database/eventTypes/ref.write',
'google.firebase.database.document.v1.updated':
'providers/google.firebase.database/eventTypes/ref.update',
'google.firebase.database.document.v1.deleted':
'providers/google.firebase.database/eventTypes/ref.delete',
})
);

// CloudEvent service names.
const FIREBASE_AUTH_CE_SERVICE = 'firebaseauth.googleapis.com';
const PUBSUB_CE_SERVICE = 'pubsub.googleapis.com';
const STORAGE_CE_SERVICE = 'storage.googleapis.com';

const PUBSUB_MESSAGE_TYPE =
'type.googleapis.com/google.pubsub.v1.PubsubMessage';

/**
* Regex to split a CE source string into service and name components.
*/
const CE_SOURCE_REGEX = /\/\/([^/]+)\/(.+)/;

/**
* Costom exception class to represent errors durring event converion.
*/
export class EventConversionError extends Error {}

/**
* Is the given request a known CloudEvent that can be converted to a legacy event.
* @param request express request object
* @returns true if the request can be converted
*/
const isConvertableCloudEvent = (request: Request): boolean => {
if (isBinaryCloudEvent(request)) {
const ceType = request.header('ce-type');
return CE_TO_BACKGROUND_TYPE.has(ceType!);
}
return false;
};

/**
* Splits a CloudEvent source string into resource and subject components.
* @param source the cloud event source
* @returns the parsed service and name components of the CE source string
*/
export const parseSource = (
source: string
): {service: string; name: string} => {
const match = source.match(CE_SOURCE_REGEX);
if (!match) {
throw new EventConversionError(
`Failed to convert CloudEvent with invalid source: "${source}"`
);
}
return {
service: match![1],
name: match![2],
};
};

/**
* Marshal a known GCP CloudEvent request the equivalent context/data legacy event format.
* @param req express request object
* @returns the request body of the equivalent legacy event request
*/
const marshallConvertableCloudEvent = (
req: Request
): {context: object; data: object} => {
const ceContext = getBinaryCloudEventContext(req);
const {service, name} = parseSource(ceContext.source!);
const subject = ceContext.subject!;
let data = req.body;

// The default resource is a string made up of the source name and subject.
let resource: string | {[key: string]: string} = `${name}/${subject}`;

switch (service) {
case PUBSUB_CE_SERVICE:
// PubSub resource format
resource = {
service: service,
name: name,
type: PUBSUB_MESSAGE_TYPE,
};
// If the data payload has a "message", it needs to be flattened
if ('message' in data) {
data = data.message;
}
break;
case FIREBASE_AUTH_CE_SERVICE:
// FirebaseAuth resource format
resource = name;
if ('metadata' in data) {
// Some metadata are not consistent between cloudevents and legacy events
const metadata: object = data.metadata;
data.metadata = {};
// eslint-disable-next-line prefer-const
for (let [k, v] of Object.entries(metadata)) {
k = k === 'createTime' ? 'createdAt' : k;
k = k === 'lastSignInTime' ? 'lastSignedInAt' : k;
data.metadata[k] = v;
}
}
break;
case STORAGE_CE_SERVICE:
// CloudStorage resource format
resource = {
name: `${name}/${subject}`,
service: service,
type: data.kind,
};
break;
}

return {
context: {
eventId: ceContext.id!,
timestamp: ceContext.time!,
eventType: CE_TO_BACKGROUND_TYPE.get(ceContext.type!),
resource,
},
data,
};
};

/**
* Express middleware to convert cloud event requests to legacy GCF events. This enables
* functions using the "EVENT" signature type to accept requests from a cloud event producer.
* @param req express request object
* @param res express response object
* @param next function used to pass control to the next middle middleware function in the stack
*/
export const ceToLegacyEventMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
if (isConvertableCloudEvent(req)) {
// This is a CloudEvent that can be converted a known legacy event.
req.body = marshallConvertableCloudEvent(req);
} else if (isBinaryCloudEvent(req)) {
// Support CloudEvents in binary content mode, with data being the whole
// request body and context attributes retrieved from request headers.
req.body = {
context: getBinaryCloudEventContext(req),
data: req.body,
};
}
next();
};
8 changes: 5 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {SignatureType} from './types';
import {setLatestRes} from './invoker';
import {registerFunctionRoutes} from './router';
import {legacyPubSubEventMiddleware} from './pubsub_middleware';
import {ceToLegacyEventMiddleware} from './middelware/ce_to_legacy_event';

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

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

registerFunctionRoutes(app, userFunction, functionSignatureType);
Expand Down
46 changes: 39 additions & 7 deletions test/integration/legacy_event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ describe('Event Function', () => {
},
data: {some: 'payload'},
},
expectedResource: {
expectedData: {some: 'payload'},
expectedContext: {
eventId: 'testEventId',
eventType: 'testEventType',
resource: 'testResource',
Expand All @@ -62,7 +63,8 @@ describe('Event Function', () => {
resource: 'testResource',
data: {some: 'payload'},
},
expectedResource: {
expectedData: {some: 'payload'},
expectedContext: {
eventId: 'testEventId',
eventType: 'testEventType',
resource: 'testResource',
Expand All @@ -85,7 +87,8 @@ describe('Event Function', () => {
},
data: {some: 'payload'},
},
expectedResource: {
expectedData: {some: 'payload'},
expectedContext: {
eventId: 'testEventId',
eventType: 'testEventType',
resource: {
Expand All @@ -100,7 +103,8 @@ describe('Event Function', () => {
name: 'CloudEvents v1.0 structured content request',
headers: {'Content-Type': 'application/cloudevents+json'},
body: TEST_CLOUD_EVENT,
expectedResource: {
expectedData: {some: 'payload'},
expectedContext: {
datacontenttype: 'application/json',
id: 'test-1234-1234',
source:
Expand All @@ -124,7 +128,8 @@ describe('Event Function', () => {
'ce-datacontenttype': TEST_CLOUD_EVENT.datacontenttype,
},
body: TEST_CLOUD_EVENT.data,
expectedResource: {
expectedData: TEST_CLOUD_EVENT.data,
expectedContext: {
datacontenttype: 'application/json',
id: 'test-1234-1234',
source:
Expand All @@ -135,6 +140,33 @@ describe('Event Function', () => {
type: 'com.google.cloud.storage',
},
},
{
name: 'Firebase Database CloudEvent',
headers: {
'ce-specversion': '1.0',
'ce-type': 'google.firebase.database.document.v1.written',
'ce-source':
'//firebasedatabase.googleapis.com/projects/_/instances/my-project-id',
'ce-subject': 'refs/gcf-test/xyz',
'ce-id': 'aaaaaa-1111-bbbb-2222-cccccccccccc',
'ce-time': '2020-09-29T11:32:00.000Z',
'ce-datacontenttype': 'application/json',
},
body: {
data: null,
delta: 10,
},
expectedData: {
data: null,
delta: 10,
},
expectedContext: {
resource: 'projects/_/instances/my-project-id/refs/gcf-test/xyz',
timestamp: '2020-09-29T11:32:00.000Z',
eventType: 'providers/google.firebase.database/eventTypes/ref.write',
eventId: 'aaaaaa-1111-bbbb-2222-cccccccccccc',
},
},
];
testData.forEach(test => {
it(test.name, async () => {
Expand All @@ -153,8 +185,8 @@ describe('Event Function', () => {
.send(test.body)
.set(requestHeaders)
.expect(204);
assert.deepStrictEqual(receivedData, {some: 'payload'});
assert.deepStrictEqual(receivedContext, test.expectedResource);
assert.deepStrictEqual(receivedData, test.expectedData);
assert.deepStrictEqual(receivedContext, test.expectedContext);
});
});
});
Loading