diff --git a/package-lock.json b/package-lock.json index 6f1315f7..63486d16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -159,6 +159,17 @@ "integrity": "sha1-8u4fMiipDurRJF+asZIusucdM2s=", "dev": true }, + "ajv": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.3.tgz", + "integrity": "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "ansi-align": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-2.0.0.tgz", @@ -233,6 +244,14 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "dev": true }, + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "requires": { + "follow-redirects": "1.5.10" + } + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -393,6 +412,16 @@ } } }, + "cloudevents": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cloudevents/-/cloudevents-3.0.1.tgz", + "integrity": "sha512-WcuOuFl0iB3XfPFjAVFtdV90JCBVlgsDtZ6Sop6C9iE3SDloXJGdPx/6QcIG1284PB+C70MxkIgtUP3BAxzd4g==", + "requires": { + "ajv": "~6.12.3", + "axios": "~0.19.2", + "uuid": "~8.2.0" + } + }, "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -824,6 +853,16 @@ "tmp": "^0.0.33" } }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, "figures": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", @@ -865,6 +904,24 @@ "is-buffer": "~2.0.3" } }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, "form-data": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", @@ -1271,6 +1328,11 @@ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, "latest-version": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-3.1.0.tgz", @@ -2103,6 +2165,11 @@ "once": "^1.3.1" } }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, "qs": { "version": "6.7.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", @@ -2700,6 +2767,14 @@ "xdg-basedir": "^3.0.0" } }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "requires": { + "punycode": "^2.1.0" + } + }, "url-parse-lax": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", @@ -2720,6 +2795,11 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, + "uuid": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.2.0.tgz", + "integrity": "sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q==" + }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/package.json b/package.json index eba3c435..759e88be 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "types": "build/src/invoker.d.ts", "dependencies": { "body-parser": "^1.18.3", + "cloudevents": "^3.0.1", "express": "^4.16.4", "minimist": "^1.2.0", "on-finished": "^2.3.0" diff --git a/src/cloudevents.ts b/src/cloudevents.ts index c3da6940..0f070fa7 100644 --- a/src/cloudevents.ts +++ b/src/cloudevents.ts @@ -13,43 +13,15 @@ // limitations under the License. import * as express from 'express'; -import { CloudEventsContext } from './functions'; +import { CloudEvent, Receiver } from 'cloudevents'; /** - * Checks whether the incoming request is a CloudEvents event in binary content - * mode. This is verified by checking the presence of required headers. - * - * @link https://github.com/cloudevents/spec/blob/master/http-protocol-binding.md#3-http-message-mapping - * - * @param req Express request object. - * @return True if the request is a CloudEvents event in binary content mode, - * false otherwise. - */ -export function isBinaryCloudEvent(req: express.Request): boolean { - return !!( - req.header('ce-type') && - req.header('ce-specversion') && - req.header('ce-source') && - req.header('ce-id') - ); -} - -/** - * Returns a CloudEvents context from the given CloudEvents request. Context - * attributes are retrieved from request headers. + * Returns a CloudEvent from the given CloudEvents HTTP request. * * @param req Express request object. - * @return CloudEvents context. + * @return The CloudEvent */ -export function getBinaryCloudEventContext( - req: express.Request -): CloudEventsContext { - const context: CloudEventsContext = {}; - for (const name in req.headers) { - if (name.startsWith('ce-')) { - const attributeName = name.substr('ce-'.length); - context[attributeName] = req.header(name); - } - } - return context; +export function getCloudEvent(req: express.Request): CloudEvent { + const headers = req.headers as { [key: string]: string }; + return Receiver.accept(headers, req.body); } diff --git a/src/functions.ts b/src/functions.ts index 8d426bb3..0bba4dcb 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -13,6 +13,7 @@ // limitations under the License. import * as express from 'express'; +import { CloudEventV1Attributes } from 'cloudevents'; export interface HttpFunction { // tslint:disable-next-line:no-any express interface. @@ -28,11 +29,11 @@ export interface EventFunctionWithCallback { } export interface CloudEventFunction { // tslint:disable-next-line:no-any - (cloudevent: CloudEventsContext): any; + (cloudevent: CloudEventV1Attributes): any; } export interface CloudEventFunctionWithCallback { // tslint:disable-next-line:no-any - (cloudevent: CloudEventsContext, callback: Function): any; + (cloudevent: CloudEventV1Attributes, callback: Function): any; } export type HandlerFunction = | HttpFunction @@ -66,43 +67,4 @@ export interface CloudFunctionsContext { resource?: string; } -/** - * The CloudEvents v1.0 context object for the event. - * - * @link https://github.com/cloudevents/spec/blob/master/spec.md#context-attributes - */ -export interface CloudEventsContext { - /** - * Type of occurrence which has happened. - */ - type?: string; - /** - * The version of the CloudEvents specification which the event uses. - */ - specversion?: string; - /** - * The event producer. - */ - source?: string; - /** - * ID of the event. - */ - id?: string; - /** - * Timestamp of when the event happened. - */ - time?: string; - /** - * A link to the schema that the event data adheres to. - */ - schemaurl?: string; - /** - * Content type of the event data. - */ - contenttype?: string; - - // tslint:disable-next-line:no-any CloudEvents extension attributes. - [key: string]: any; -} - -export type Context = CloudFunctionsContext | CloudEventsContext; +export type Context = CloudFunctionsContext | CloudEventV1Attributes; diff --git a/src/invoker.ts b/src/invoker.ts index a2d21254..be60e37d 100644 --- a/src/invoker.ts +++ b/src/invoker.ts @@ -28,7 +28,7 @@ import * as onFinished from 'on-finished'; import { FUNCTION_STATUS_HEADER_FIELD } from './types'; import { logAndSendError } from './logger'; -import { isBinaryCloudEvent, getBinaryCloudEventContext } from './cloudevents'; +import { getCloudEvent } from './cloudevents'; import { HttpFunction, EventFunction, @@ -144,11 +144,7 @@ function wrapCloudEventFunction( } } ); - let cloudevent = req.body; - if (isBinaryCloudEvent(req)) { - cloudevent = getBinaryCloudEventContext(req); - cloudevent.data = req.body; - } + const cloudevent = getCloudEvent(req); // Callback style if user function has more than 2 arguments. if (userFunction!.length > 2) { const fn = userFunction as CloudEventFunctionWithCallback; @@ -197,17 +193,11 @@ 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) { - // Support legacy events and CloudEvents in structured content mode, with - // context properties represented as event top-level properties. - // Context is everything but data. + if (context === undefined) { + // Support legacy events with context properties represented as event + // top-level properties. Context is everything but data. context = event; // Clear the property before removing field so the data object // is not deleted. diff --git a/test/invoker.ts b/test/invoker.ts index 0f951157..12a7f17f 100644 --- a/test/invoker.ts +++ b/test/invoker.ts @@ -17,6 +17,7 @@ import * as express from 'express'; import * as functions from '../src/functions'; import * as invoker from '../src/invoker'; import * as supertest from 'supertest'; +import { CloudEventV1, CloudEventV1Attributes } from 'cloudevents'; describe('request to HTTP function', () => { interface TestData { @@ -150,7 +151,7 @@ describe('GCF event request to event function', () => { }); }); -const TEST_CLOUD_EVENT = { +const TEST_CLOUD_EVENT: CloudEventV1 = { specversion: '1.0', type: 'com.google.cloud.storage', source: 'https://github.com/GoogleCloudPlatform/functions-framework-nodejs', @@ -166,7 +167,7 @@ const TEST_CLOUD_EVENT = { describe('CloudEvents request to event function', () => { interface TestData { name: string; - headers: { [key: string]: string }; + headers: { [key: string]: string | undefined }; body: {}; } @@ -185,20 +186,21 @@ describe('CloudEvents request to event function', () => { 'ce-source': TEST_CLOUD_EVENT.source, 'ce-subject': TEST_CLOUD_EVENT.subject, 'ce-id': TEST_CLOUD_EVENT.id, - 'ce-time': TEST_CLOUD_EVENT.time, + 'ce-time': TEST_CLOUD_EVENT.time as string, 'ce-datacontenttype': TEST_CLOUD_EVENT.datacontenttype, }, - body: TEST_CLOUD_EVENT.data, + // tslint:disable-next-line:no-any + body: TEST_CLOUD_EVENT.data as any, // disable bug in interface for unknown type }, ]; testData.forEach(test => { it(`should receive data and context from ${test.name}`, async () => { let receivedData: {} | null = null; - let receivedContext: functions.CloudEventsContext | null = null; + let receivedContext: CloudEventV1Attributes | null = null; const server = invoker.getServer( (data: {}, context: functions.Context) => { receivedData = data; - receivedContext = context as functions.CloudEventsContext; + receivedContext = context as CloudEventV1Attributes; }, invoker.SignatureType.EVENT ); @@ -229,7 +231,7 @@ describe('CloudEvents request to event function', () => { describe('CloudEvents request to cloudevent function', () => { interface TestData { name: string; - headers: { [key: string]: string }; + headers: { [key: string]: string | undefined }; body: {}; } @@ -248,21 +250,19 @@ describe('CloudEvents request to cloudevent function', () => { 'ce-source': TEST_CLOUD_EVENT.source, 'ce-subject': TEST_CLOUD_EVENT.subject, 'ce-id': TEST_CLOUD_EVENT.id, - 'ce-time': TEST_CLOUD_EVENT.time, + 'ce-time': TEST_CLOUD_EVENT.time as string, 'ce-datacontenttype': TEST_CLOUD_EVENT.datacontenttype, }, - body: TEST_CLOUD_EVENT.data, + // tslint:disable-next-line:no-any + body: TEST_CLOUD_EVENT.data as any, // disable bug in interface for unknown type, }, ]; testData.forEach(test => { it(`should receive data and context from ${test.name}`, async () => { - let receivedCloudEvent: functions.CloudEventsContext | null = null; - const server = invoker.getServer( - (cloudevent: functions.CloudEventsContext) => { - receivedCloudEvent = cloudevent as functions.CloudEventsContext; - }, - invoker.SignatureType.CLOUDEVENT - ); + let receivedCloudEvent: CloudEventV1Attributes | null = null; + const server = invoker.getServer((cloudevent: functions.Context) => { + receivedCloudEvent = cloudevent as CloudEventV1Attributes; + }, invoker.SignatureType.CLOUDEVENT); await supertest(server) .post('/') .set(test.headers)