Skip to content

fix: Make CloudEvent data field immutable and enumerable #516

New issue

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

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

Already on GitHub? # to your account

Merged
merged 1 commit into from
Nov 1, 2022
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,6 @@ typings/

# Package lock
package-lock.json

# Jetbrains IDE directories
.idea
8 changes: 4 additions & 4 deletions examples/express-ex/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ app.post("/", (req, res) => {
const responseEventMessage = new CloudEvent({
source: '/',
type: 'event:response',
...event
...event,
data: {
hello: 'world'
}
});
responseEventMessage.data = {
hello: 'world'
};

// const message = HTTP.binary(responseEventMessage)
const message = HTTP.structured(responseEventMessage)
Expand Down
35 changes: 17 additions & 18 deletions src/event/cloudevent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Emitter } from "..";

import { CloudEventV1 } from "./interfaces";
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
Expand All @@ -33,7 +33,7 @@ export class CloudEvent<T = undefined> implements CloudEventV1<T> {
dataschema?: string;
subject?: string;
time?: string;
#_data?: T;
data?: T;
data_base64?: string;

// Extensions should not exist as it's own object, but instead
Expand Down Expand Up @@ -85,12 +85,21 @@ export class CloudEvent<T = undefined> implements CloudEventV1<T> {
delete properties.dataschema;

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;

this.schemaurl = properties.schemaurl as string;
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;

// sanity checking
Expand Down Expand Up @@ -127,17 +136,6 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`);
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
* JSON.stringify() when converting this object to JSON.
Expand All @@ -147,7 +145,11 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`);
toJSON(): Record<string, unknown> {
const event = { ...this };
event.time = new Date(this.time as string).toISOString();
event.data = this.#_data;

if (event.data_base64 && event.data) {
delete event.data;
}

return event;
}

Expand Down Expand Up @@ -230,9 +232,6 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`);
event: CloudEventV1<any>,
options: Partial<CloudEventV1<any>>,
strict = true): CloudEvent<any> {
if (event instanceof CloudEvent) {
event = event.toJSON() as CloudEventV1<any>;
}
return new CloudEvent(Object.assign({}, event, options), strict);
}
}
21 changes: 21 additions & 0 deletions src/event/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ import { ErrorObject } from "ajv";
export type TypeArray = Int8Array | Uint8Array | Int16Array | Uint16Array |
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
Expand Down Expand Up @@ -86,6 +99,14 @@ export const asBuffer = (value: string | Buffer | TypeArray): Buffer =>
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 =
(value: string | Buffer | TypeArray): string => asBuffer(value).toString("base64");

Expand Down
15 changes: 9 additions & 6 deletions src/message/mqtt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { Binding, Deserializer, CloudEvent, CloudEventV1, CONSTANTS, Message, ValidationError, Headers } from "../..";
import { base64AsBinary } from "../../event/validation";

export {
MQTT, MQTTMessageFactory
Expand Down Expand Up @@ -50,14 +51,16 @@ const MQTT: Binding = {
* @implements {Serializer}
*/
function binary<T>(event: CloudEventV1<T>): MQTTMessage<T> {
let properties;
if (event instanceof CloudEvent) {
properties = event.toJSON();
} else {
properties = event;
const properties = { ...event };

let body = properties.data as T;

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_base64;

return MQTTMessageFactory(event.datacontenttype as string, properties, body);
}
Expand Down
75 changes: 71 additions & 4 deletions test/integration/cloud_event_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,39 @@ import path from "path";
import fs from "fs";

import { expect } from "chai";
import { CloudEvent, ValidationError, Version } from "../../src";
import { CloudEvent, CloudEventV1, ValidationError, Version } from "../../src";
import { asBase64 } from "../../src/event/validation";

const type = "org.cncf.cloudevents.example";
const source = "http://unit.test";
const id = "b46cf653-d48a-4b90-8dfa-355c01061361";

const fixture = {
const fixture = Object.freeze({
id,
specversion: Version.V1,
source,
type,
data: `"some data"`,
};
data: `"some data"`
});

const imageData = new Uint32Array(fs.readFileSync(path.join(process.cwd(), "test", "integration", "ce.png")));
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", () => {
it("Can be constructed with a typed Message", () => {
const ce = new CloudEvent(fixture);
Expand Down Expand Up @@ -78,6 +93,58 @@ describe("A CloudEvent", () => {
new CloudEvent({ ExtensionWithCaps: "extension value", ...fixture });
}).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", () => {
Expand Down
10 changes: 5 additions & 5 deletions test/integration/mqtt_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ const ext2Name = "extension2";
const ext2Value = "acme";

// 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);

// 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
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 PUBLISH = {"Content Type": "application/json; charset=utf-8"};
Expand Down Expand Up @@ -281,14 +281,14 @@ describe("MQTT transport", () => {

it("Converts base64 encoded data to binary when deserializing structured messages", () => {
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_base64).to.equal(image_base64);
});

it("Converts base64 encoded data to binary when deserializing binary messages", () => {
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_base64).to.equal(image_base64);
});
Expand All @@ -302,7 +302,7 @@ describe("MQTT transport", () => {

it("Does not parse binary data from binary messages with content type application/json", () => {
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_base64).to.equal(data_base64);
});
Expand Down
12 changes: 6 additions & 6 deletions test/integration/spec_1_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const data = {
};
const subject = "subject-x0";

let cloudevent = new CloudEvent({
const cloudevent = new CloudEvent({
specversion: Version.V1,
id,
source,
Expand Down Expand Up @@ -120,8 +120,8 @@ describe("CloudEvents Spec v1.0", () => {
});

it("defaut ID create when an empty string", () => {
cloudevent = cloudevent.cloneWith({ id: "" });
expect(cloudevent.id.length).to.be.greaterThan(0);
const testEvent = cloudevent.cloneWith({ id: "" });
expect(testEvent.id.length).to.be.greaterThan(0);
});
});

Expand Down Expand Up @@ -160,11 +160,11 @@ describe("CloudEvents Spec v1.0", () => {
describe("'time'", () => {
it("must adhere to the format specified in RFC 3339", () => {
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
expect(cloudevent.time).to.equal(d.toString());
expect(testEvent.time).to.equal(d.toString());
// 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());
});
});
});
Expand Down