diff --git a/README.md b/README.md index f0256adb..2a2979d8 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,32 @@ const event3 = new CloudEvent({ }); ``` +### Extension Attributes + +Any property passed to the `CloudEvent` constructor that is not a core attribute becomes an +[extension attribute](https://github.com/cloudevents/spec/blob/v1.0/spec.md#extension-context-attributes). +In binary mode each extension is sent as its own header, named after the attribute with the +transport's prefix: an extension `traceid` becomes the `ce-traceid` HTTP header. Extension names +must be lowercase letters and digits only. + +Extension values should be one of the CloudEvents types: string, boolean, integer, binary, URI or +timestamp. The spec has no object type, so encode structured data as a string yourself: + +```js +const ce = new CloudEvent({ + source: "/orders", + type: "order.placed", + traceinfo: JSON.stringify({ span: "abc", sampled: true }), +}); +``` + +`Date` values are sent as RFC 3339 timestamps and binary values (`Buffer`, typed arrays) as base64, which +are the string forms the spec requires. + +As a convenience the SDK also accepts a plain object or array as an extension value and serializes it as JSON +when the event is sent in binary mode. This is not spec-conformant: a receiver gets a plain string +and has to know to parse it, and other SDKs may reject the event. Prefer the explicit form above. + ### A Note About Big Integers When parsing JSON data, if a JSON field value is a number, and that number diff --git a/src/event/interfaces.ts b/src/event/interfaces.ts index f73c6d8f..72d3843b 100644 --- a/src/event/interfaces.ts +++ b/src/event/interfaces.ts @@ -136,7 +136,19 @@ export interface CloudEventV1OptionalAttributes { data_base64?: string; /** - * [OPTIONAL] CloudEvents extension attributes. + * [OPTIONAL] CloudEvents extension attributes. Any property that is not a + * core attribute is treated as an extension. Values should be one of the + * CloudEvents types: string, boolean, integer, binary, URI or timestamp. + * In binary mode a Date is sent as RFC 3339 and binary values as base64. + * + * Object values are accepted as a convenience and for compatibility reasons. + * They are serialized as JSON in binary mode, but this is not spec-conformant; + * prefer encoding the value as a string yourself. + * + * @deprecated automatic serialization of object values is not spec-conformant + * and may be removed in future versions of the SDK. + * + * @see https://github.com/cloudevents/spec/blob/v1.0/spec.md#extension-context-attributes */ [key: string]: unknown; } diff --git a/src/message/http/headers.ts b/src/message/http/headers.ts index b0cc519b..15fb15e4 100644 --- a/src/message/http/headers.ts +++ b/src/message/http/headers.ts @@ -8,6 +8,7 @@ import { CloudEventV1 } from "../.."; import { Headers } from "../"; import { V1 } from "../../event/cloudevent"; import CONSTANTS from "../../constants"; +import { asBase64, isBinary, isDate, TypeArray } from "../../event/validation"; export const allowedContentTypes = [CONSTANTS.DEFAULT_CONTENT_TYPE, CONSTANTS.MIME_JSON, CONSTANTS.MIME_OCTET_STREAM]; export const requiredHeaders = [ @@ -41,7 +42,7 @@ export function headersFor(event: CloudEventV1): Headers { if (map) { headers[map.name] = map.parser.parse(value as string) as string; } else if (property !== CONSTANTS.DATA_ATTRIBUTE && property !== `${CONSTANTS.DATA_ATTRIBUTE}_base64`) { - headers[`${CONSTANTS.EXTENSIONS_PREFIX}${property}`] = value as string; + headers[`${CONSTANTS.EXTENSIONS_PREFIX}${property}`] = stringifyExtensionValue(value); } } }); @@ -52,6 +53,55 @@ export function headersFor(event: CloudEventV1): Headers { return headers; } +/** + * True for objects created by a literal, `new Object()` or `Object.create(null)`. + * The check looks at the prototype, so it is not fooled by a shadowed `constructor` + * property and it does not depend on `Object.prototype.toString`, which a + * Symbol.toStringTag can spoof. Class instances (Error, Map, Date, ...) are not plain. + * @param {unknown} value the value to test + * @returns {boolean} true if the value is a plain object + */ +const isPlainObject = (value: unknown): value is Record => + value !== null && + typeof value === "object" && + (Object.getPrototypeOf(value) === Object.prototype || + (Object.getPrototypeOf(value) === null && Object.prototype.toString.call(value) === "[object Object]")); + +/** + * Converts an extension attribute value to its binary-mode header value. + * + * The CloudEvents type system has no object type: extension values are strings, + * booleans, integers, binary, URIs or timestamps. Values that are not already + * strings are converted as follows; the branches are disjoint, so their order + * does not matter. The last column is what the HTTP client's String() coercion + * used to put on the wire. + * + * | value | header value | before | + * |--------------------------|-------------------------|---------------------------------| + * | Date | RFC 3339 (toISOString) | local-time string | + * | Buffer, typed array | base64 | UTF-8 text, or "104,105" | + * | plain object, array | JSON (convenience) | "[object Object]", or "1,2" | + * | anything else | unchanged | unchanged | + * + * JSON for plain objects and arrays is an SDK convenience, not spec behavior; + * prefer encoding such values as strings yourself. Anything else (Error, Map, + * null, ...) is not a CloudEvents type and is left for the HTTP client, as before. + * @param {unknown} value the extension attribute value + * @returns {string} the header value + */ +function stringifyExtensionValue(value: unknown): string { + if (isDate(value)) { + return value.toISOString(); + } + if (isBinary(value)) { + return asBase64(value as TypeArray); + } + if (isPlainObject(value) || Array.isArray(value)) { + return JSON.stringify(value); + } + return value as string; +} + /** * Sanitizes incoming headers by lowercasing them and potentially removing * encoding from the content-type header. diff --git a/test/integration/message_test.ts b/test/integration/message_test.ts index 9ddb292d..8ac2200f 100644 --- a/test/integration/message_test.ts +++ b/test/integration/message_test.ts @@ -65,6 +65,54 @@ describe("HTTP transport", () => { expect(message.headers["ce-extboolean"]).to.equal(false); }); + it("Serializes object-valued extensions as JSON in binary mode", () => { + const extobject = { some: "object", nested: { answer: 42 } }; + const evt = new CloudEvent({ source: "test", type: "test", extobject }); + const message = HTTP.binary(evt); + // HTTP headers can only carry strings; a raw object reaches the wire as "[object Object]" + expect(message.headers["ce-extobject"]).to.equal(JSON.stringify(extobject)); + }); + + it("Round-trips an object-valued extension through binary mode as a JSON string", () => { + const extobject = { some: "object", nested: { answer: 42 } }; + const evt = new CloudEvent({ source: "test", type: "test", extobject }); + const received = HTTP.toEvent(HTTP.binary(evt)) as CloudEvent; + // the receiver sees the serialized form and is responsible for parsing it + expect(received.extobject).to.equal(JSON.stringify(extobject)); + expect(JSON.parse(received.extobject as string)).to.deep.equal(extobject); + }); + + it("Serializes Date-valued extensions as RFC 3339 timestamps in binary mode", () => { + const extdate = new Date("2018-04-05T03:56:24Z"); + const message = HTTP.binary(new CloudEvent({ source: "test", type: "test", extdate })); + expect(message.headers["ce-extdate"]).to.equal("2018-04-05T03:56:24.000Z"); + }); + + it("Serializes binary extensions as base64 in binary mode", () => { + const extbytes = new Uint8Array([104, 105]); + const extbuffer = Buffer.from("hi"); + const message = HTTP.binary(new CloudEvent({ source: "test", type: "test", extbytes, extbuffer })); + expect(message.headers["ce-extbytes"]).to.equal("aGk="); + expect(message.headers["ce-extbuffer"]).to.equal("aGk="); + }); + + it("Serializes plain objects without an Object constructor as JSON in binary mode", () => { + // a shadowed `constructor` property or a null prototype must not defeat plain-object detection + const extshadowed = { constructor: "shadowed", some: "object" }; + const extnullproto = Object.assign(Object.create(null), { some: "object" }); + const message = HTTP.binary(new CloudEvent({ source: "test", type: "test", extshadowed, extnullproto })); + expect(message.headers["ce-extshadowed"]).to.equal(JSON.stringify(extshadowed)); + expect(message.headers["ce-extnullproto"]).to.equal(JSON.stringify(extnullproto)); + }); + + it("Leaves non-CloudEvents values in binary-mode extensions untouched", () => { + const exterror = new Error("useful message"); + const message = HTTP.binary(new CloudEvent({ source: "test", type: "test", exterror })); + // JSON.stringify would turn the Error into "{}"; the HTTP client's String() keeps the message + expect(message.headers["ce-exterror"]).to.equal(exterror); + expect(String(message.headers["ce-exterror"])).to.equal("Error: useful message"); + }); + it("Includes extensions in structured when type is 'boolean' with a false value", () => { const evt = new CloudEvent({ source: "test", type: "test", extboolean: false }); expect(evt.hasOwnProperty("extboolean")).to.equal(true);