fix: Make CloudEvent data field immutable and enumerable using Object.keys() (#515) (#516)

Signed-off-by: Philip Sanetra <code@psanetra.de>
This commit is contained in:
Philip Sanetra 2022-11-01 17:46:26 +01:00 committed by GitHub
parent c09a9cc20a
commit 2d5fab1b71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 136 additions and 43 deletions

3
.gitignore vendored
View File

@ -91,3 +91,6 @@ typings/
# Package lock # Package lock
package-lock.json package-lock.json
# Jetbrains IDE directories
.idea

View File

@ -28,11 +28,11 @@ app.post("/", (req, res) => {
const responseEventMessage = new CloudEvent({ const responseEventMessage = new CloudEvent({
source: '/', source: '/',
type: 'event:response', type: 'event:response',
...event ...event,
data: {
hello: 'world'
}
}); });
responseEventMessage.data = {
hello: 'world'
};
// const message = HTTP.binary(responseEventMessage) // const message = HTTP.binary(responseEventMessage)
const message = HTTP.structured(responseEventMessage) const message = HTTP.structured(responseEventMessage)

View File

@ -9,7 +9,7 @@ import { Emitter } from "..";
import { CloudEventV1 } from "./interfaces"; import { CloudEventV1 } from "./interfaces";
import { validateCloudEvent } from "./spec"; import { validateCloudEvent } from "./spec";
import { ValidationError, isBinary, asBase64, isValidType } from "./validation"; import { ValidationError, isBinary, asBase64, isValidType, base64AsBinary } from "./validation";
/** /**
* An enum representing the CloudEvent specification version * An enum representing the CloudEvent specification version
@ -33,7 +33,7 @@ export class CloudEvent<T = undefined> implements CloudEventV1<T> {
dataschema?: string; dataschema?: string;
subject?: string; subject?: string;
time?: string; time?: string;
#_data?: T; data?: T;
data_base64?: string; data_base64?: string;
// Extensions should not exist as it's own object, but instead // Extensions should not exist as it's own object, but instead
@ -85,12 +85,21 @@ export class CloudEvent<T = undefined> implements CloudEventV1<T> {
delete properties.dataschema; delete properties.dataschema;
this.data_base64 = properties.data_base64 as string; this.data_base64 = properties.data_base64 as string;
if (this.data_base64) {
this.data = base64AsBinary(this.data_base64) as unknown as T;
}
delete properties.data_base64; delete properties.data_base64;
this.schemaurl = properties.schemaurl as string; this.schemaurl = properties.schemaurl as string;
delete properties.schemaurl; delete properties.schemaurl;
this.data = properties.data; if (isBinary(properties.data)) {
this.data_base64 = asBase64(properties.data as unknown as Buffer);
}
this.data = typeof properties.data !== "undefined" ? properties.data : this.data;
delete properties.data; delete properties.data;
// sanity checking // sanity checking
@ -127,17 +136,6 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`);
Object.freeze(this); Object.freeze(this);
} }
get data(): T | undefined {
return this.#_data;
}
set data(value: T | undefined) {
if (isBinary(value)) {
this.data_base64 = asBase64(value as unknown as Buffer);
}
this.#_data = value;
}
/** /**
* Used by JSON.stringify(). The name is confusing, but this method is called by * Used by JSON.stringify(). The name is confusing, but this method is called by
* JSON.stringify() when converting this object to JSON. * JSON.stringify() when converting this object to JSON.
@ -147,7 +145,11 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`);
toJSON(): Record<string, unknown> { toJSON(): Record<string, unknown> {
const event = { ...this }; const event = { ...this };
event.time = new Date(this.time as string).toISOString(); event.time = new Date(this.time as string).toISOString();
event.data = this.#_data;
if (event.data_base64 && event.data) {
delete event.data;
}
return event; return event;
} }
@ -230,9 +232,6 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`);
event: CloudEventV1<any>, event: CloudEventV1<any>,
options: Partial<CloudEventV1<any>>, options: Partial<CloudEventV1<any>>,
strict = true): CloudEvent<any> { strict = true): CloudEvent<any> {
if (event instanceof CloudEvent) {
event = event.toJSON() as CloudEventV1<any>;
}
return new CloudEvent(Object.assign({}, event, options), strict); return new CloudEvent(Object.assign({}, event, options), strict);
} }
} }

View File

@ -8,6 +8,19 @@ import { ErrorObject } from "ajv";
export type TypeArray = Int8Array | Uint8Array | Int16Array | Uint16Array | export type TypeArray = Int8Array | Uint8Array | Int16Array | Uint16Array |
Int32Array | Uint32Array | Uint8ClampedArray | Float32Array | Float64Array; Int32Array | Uint32Array | Uint8ClampedArray | Float32Array | Float64Array;
const globalThisPolyfill = (function() {
try {
return globalThis;
}
catch (e) {
try {
return self;
}
catch (e) {
return global;
}
}
}());
/** /**
* An Error class that will be thrown when a CloudEvent * An Error class that will be thrown when a CloudEvent
@ -86,6 +99,14 @@ export const asBuffer = (value: string | Buffer | TypeArray): Buffer =>
throw new TypeError("is not buffer or a valid binary"); throw new TypeError("is not buffer or a valid binary");
})(); })();
export const base64AsBinary = (base64String: string): Uint8Array => {
const toBinaryString = (base64Str: string): string => globalThisPolyfill.atob
? globalThisPolyfill.atob(base64Str)
: Buffer.from(base64Str, "base64").toString("binary");
return Uint8Array.from(toBinaryString(base64String), (c) => c.charCodeAt(0));
};
export const asBase64 = export const asBase64 =
(value: string | Buffer | TypeArray): string => asBuffer(value).toString("base64"); (value: string | Buffer | TypeArray): string => asBuffer(value).toString("base64");

View File

@ -4,6 +4,7 @@
*/ */
import { Binding, Deserializer, CloudEvent, CloudEventV1, CONSTANTS, Message, ValidationError, Headers } from "../.."; import { Binding, Deserializer, CloudEvent, CloudEventV1, CONSTANTS, Message, ValidationError, Headers } from "../..";
import { base64AsBinary } from "../../event/validation";
export { export {
MQTT, MQTTMessageFactory MQTT, MQTTMessageFactory
@ -50,14 +51,16 @@ const MQTT: Binding = {
* @implements {Serializer} * @implements {Serializer}
*/ */
function binary<T>(event: CloudEventV1<T>): MQTTMessage<T> { function binary<T>(event: CloudEventV1<T>): MQTTMessage<T> {
let properties; const properties = { ...event };
if (event instanceof CloudEvent) {
properties = event.toJSON(); let body = properties.data as T;
} else {
properties = event; if (!body && properties.data_base64) {
body = base64AsBinary(properties.data_base64) as unknown as T;
} }
const body = properties.data as T;
delete properties.data; delete properties.data;
delete properties.data_base64;
return MQTTMessageFactory(event.datacontenttype as string, properties, body); return MQTTMessageFactory(event.datacontenttype as string, properties, body);
} }

View File

@ -7,24 +7,39 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import { expect } from "chai"; import { expect } from "chai";
import { CloudEvent, ValidationError, Version } from "../../src"; import { CloudEvent, CloudEventV1, ValidationError, Version } from "../../src";
import { asBase64 } from "../../src/event/validation"; import { asBase64 } from "../../src/event/validation";
const type = "org.cncf.cloudevents.example"; const type = "org.cncf.cloudevents.example";
const source = "http://unit.test"; const source = "http://unit.test";
const id = "b46cf653-d48a-4b90-8dfa-355c01061361"; const id = "b46cf653-d48a-4b90-8dfa-355c01061361";
const fixture = { const fixture = Object.freeze({
id, id,
specversion: Version.V1, specversion: Version.V1,
source, source,
type, type,
data: `"some data"`, data: `"some data"`
}; });
const imageData = new Uint32Array(fs.readFileSync(path.join(process.cwd(), "test", "integration", "ce.png"))); const imageData = new Uint32Array(fs.readFileSync(path.join(process.cwd(), "test", "integration", "ce.png")));
const image_base64 = asBase64(imageData); const image_base64 = asBase64(imageData);
// Do not replace this with the assignment of a class instance
// as we just want to test if we can enumerate all explicitly defined fields!
const cloudEventV1InterfaceFields: (keyof CloudEventV1<unknown>)[] = Object.keys({
id: "",
type: "",
data: undefined,
data_base64: "",
source: "",
time: "",
datacontenttype: "",
dataschema: "",
specversion: "",
subject: ""
} as Required<CloudEventV1<unknown>>);
describe("A CloudEvent", () => { describe("A CloudEvent", () => {
it("Can be constructed with a typed Message", () => { it("Can be constructed with a typed Message", () => {
const ce = new CloudEvent(fixture); const ce = new CloudEvent(fixture);
@ -78,6 +93,58 @@ describe("A CloudEvent", () => {
new CloudEvent({ ExtensionWithCaps: "extension value", ...fixture }); new CloudEvent({ ExtensionWithCaps: "extension value", ...fixture });
}).throw("invalid extension name"); }).throw("invalid extension name");
}); });
it("CloudEventV1 interface fields should be enumerable", () => {
const classInstanceKeys = Object.keys(new CloudEvent({ ...fixture }));
for (const key of cloudEventV1InterfaceFields) {
expect(classInstanceKeys).to.contain(key);
}
});
it("throws TypeError on trying to set any field value", () => {
const ce = new CloudEvent({
...fixture,
mycustomfield: "initialValue"
});
const keySet = new Set([...cloudEventV1InterfaceFields, ...Object.keys(ce)]);
expect(keySet).not.to.be.empty;
for (const cloudEventKey of keySet) {
let threw = false;
try {
ce[cloudEventKey] = "newValue";
} catch (err) {
threw = true;
expect(err).to.be.instanceOf(TypeError);
expect((err as TypeError).message).to.include("Cannot assign to read only property");
}
if (!threw) {
expect.fail(`Assigning a value to ${cloudEventKey} did not throw`);
}
}
});
describe("toJSON()", () => {
it("does not return data field if data_base64 field is set to comply with JSON format spec 3.1.1", () => {
const binaryData = new Uint8Array([1,2,3]);
const ce = new CloudEvent({
...fixture,
data: binaryData
});
expect(ce.data).to.be.equal(binaryData);
const json = ce.toJSON();
expect(json.data).to.not.exist;
expect(json.data_base64).to.be.equal("AQID");
});
});
}); });
describe("A 1.0 CloudEvent", () => { describe("A 1.0 CloudEvent", () => {

View File

@ -32,12 +32,12 @@ const ext2Name = "extension2";
const ext2Value = "acme"; const ext2Value = "acme";
// Binary data as base64 // Binary data as base64
const dataBinary = Uint32Array.from(JSON.stringify(data), (c) => c.codePointAt(0) as number); const dataBinary = Uint8Array.from(JSON.stringify(data), (c) => c.codePointAt(0) as number);
const data_base64 = asBase64(dataBinary); const data_base64 = asBase64(dataBinary);
// Since the above is a special case (string as binary), let's test // Since the above is a special case (string as binary), let's test
// with a real binary file one is likely to encounter in the wild // with a real binary file one is likely to encounter in the wild
const imageData = new Uint32Array(fs.readFileSync(path.join(process.cwd(), "test", "integration", "ce.png"))); const imageData = new Uint8Array(fs.readFileSync(path.join(process.cwd(), "test", "integration", "ce.png")));
const image_base64 = asBase64(imageData); const image_base64 = asBase64(imageData);
const PUBLISH = {"Content Type": "application/json; charset=utf-8"}; const PUBLISH = {"Content Type": "application/json; charset=utf-8"};
@ -281,14 +281,14 @@ describe("MQTT transport", () => {
it("Converts base64 encoded data to binary when deserializing structured messages", () => { it("Converts base64 encoded data to binary when deserializing structured messages", () => {
const message = MQTT.structured(fixture.cloneWith({ data: imageData, datacontenttype: "image/png" })); const message = MQTT.structured(fixture.cloneWith({ data: imageData, datacontenttype: "image/png" }));
const eventDeserialized = MQTT.toEvent(message) as CloudEvent<Uint32Array>; const eventDeserialized = MQTT.toEvent(message) as CloudEvent<Uint8Array>;
expect(eventDeserialized.data).to.deep.equal(imageData); expect(eventDeserialized.data).to.deep.equal(imageData);
expect(eventDeserialized.data_base64).to.equal(image_base64); expect(eventDeserialized.data_base64).to.equal(image_base64);
}); });
it("Converts base64 encoded data to binary when deserializing binary messages", () => { it("Converts base64 encoded data to binary when deserializing binary messages", () => {
const message = MQTT.binary(fixture.cloneWith({ data: imageData, datacontenttype: "image/png" })); const message = MQTT.binary(fixture.cloneWith({ data: imageData, datacontenttype: "image/png" }));
const eventDeserialized = MQTT.toEvent(message) as CloudEvent<Uint32Array>; const eventDeserialized = MQTT.toEvent(message) as CloudEvent<Uint8Array>;
expect(eventDeserialized.data).to.deep.equal(imageData); expect(eventDeserialized.data).to.deep.equal(imageData);
expect(eventDeserialized.data_base64).to.equal(image_base64); expect(eventDeserialized.data_base64).to.equal(image_base64);
}); });
@ -302,7 +302,7 @@ describe("MQTT transport", () => {
it("Does not parse binary data from binary messages with content type application/json", () => { it("Does not parse binary data from binary messages with content type application/json", () => {
const message = MQTT.binary(fixture.cloneWith({ data: dataBinary })); const message = MQTT.binary(fixture.cloneWith({ data: dataBinary }));
const eventDeserialized = MQTT.toEvent(message) as CloudEvent<Uint32Array>; const eventDeserialized = MQTT.toEvent(message) as CloudEvent<Uint8Array>;
expect(eventDeserialized.data).to.deep.equal(dataBinary); expect(eventDeserialized.data).to.deep.equal(dataBinary);
expect(eventDeserialized.data_base64).to.equal(data_base64); expect(eventDeserialized.data_base64).to.equal(data_base64);
}); });

View File

@ -19,7 +19,7 @@ const data = {
}; };
const subject = "subject-x0"; const subject = "subject-x0";
let cloudevent = new CloudEvent({ const cloudevent = new CloudEvent({
specversion: Version.V1, specversion: Version.V1,
id, id,
source, source,
@ -120,8 +120,8 @@ describe("CloudEvents Spec v1.0", () => {
}); });
it("defaut ID create when an empty string", () => { it("defaut ID create when an empty string", () => {
cloudevent = cloudevent.cloneWith({ id: "" }); const testEvent = cloudevent.cloneWith({ id: "" });
expect(cloudevent.id.length).to.be.greaterThan(0); expect(testEvent.id.length).to.be.greaterThan(0);
}); });
}); });
@ -160,11 +160,11 @@ describe("CloudEvents Spec v1.0", () => {
describe("'time'", () => { describe("'time'", () => {
it("must adhere to the format specified in RFC 3339", () => { it("must adhere to the format specified in RFC 3339", () => {
const d = new Date(); const d = new Date();
cloudevent = cloudevent.cloneWith({ time: d.toString() }, false); const testEvent = cloudevent.cloneWith({ time: d.toString() }, false);
// ensure that we always get back the same thing we passed in // ensure that we always get back the same thing we passed in
expect(cloudevent.time).to.equal(d.toString()); expect(testEvent.time).to.equal(d.toString());
// ensure that when stringified, the timestamp is in RFC3339 format // ensure that when stringified, the timestamp is in RFC3339 format
expect(JSON.parse(JSON.stringify(cloudevent)).time).to.equal(new Date(d.toString()).toISOString()); expect(JSON.parse(JSON.stringify(testEvent)).time).to.equal(new Date(d.toString()).toISOString());
}); });
}); });
}); });