From c7a84772d59647e2a94991a3051b37b097b0d404 Mon Sep 17 00:00:00 2001 From: Lucas Holmquist Date: Mon, 13 Jul 2020 15:15:32 -0400 Subject: [PATCH] feat(src): A CloudEvent should be readonly but provide a way to augment itself. (#234) BREAKING CHANGE: * This change makes the CloudEvent Read-only and validates the input during object creation. * To augment an already created CloudEvent object, we have added a `cloneWith` method that takes attributes to add/update. Signed-off-by: Lucas Holmquist --- src/event/cloudevent.ts | 26 ++++++- src/event/v03/cloudevent.ts | 4 +- src/event/v1/cloudevent.ts | 4 +- test/integration/cloud_event_test.ts | 2 +- test/integration/http_binding_03.ts | 8 +-- test/integration/http_binding_1.ts | 13 ++-- test/integration/spec_03_tests.ts | 104 ++++++++++++++------------- test/integration/spec_1_tests.ts | 93 +++++++++--------------- 8 files changed, 131 insertions(+), 123 deletions(-) diff --git a/src/event/cloudevent.ts b/src/event/cloudevent.ts index 72dfb10..ecacbfb 100644 --- a/src/event/cloudevent.ts +++ b/src/event/cloudevent.ts @@ -1,7 +1,7 @@ import { v4 as uuidv4 } from "uuid"; -import { CloudEventV1, validateV1, CloudEventV1Attributes } from "./v1"; -import { CloudEventV03, validateV03, CloudEventV03Attributes } from "./v03"; +import { CloudEventV1, validateV1, CloudEventV1Attributes, CloudEventV1OptionalAttributes } from "./v1"; +import { CloudEventV03, validateV03, CloudEventV03Attributes, CloudEventV03OptionalAttributes } from "./v03"; import { ValidationError, isBinary, asBase64 } from "./validation"; import CONSTANTS from "../constants"; import { isString } from "util"; @@ -98,6 +98,10 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 { for (const [key, value] of Object.entries(properties)) { this[key] = value; } + + this.validate(); + + Object.freeze(this); } get time(): string | Date { @@ -165,4 +169,22 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 { } } } + + /** + * Clone a CloudEvent with new/update attributes + * @param {object} options attributes to augment the CloudEvent with + * @throws if the CloudEvent does not conform to the schema + * @return {CloudEvent} returns a new CloudEvent + */ + public cloneWith( + options: + | CloudEventV1 + | CloudEventV1Attributes + | CloudEventV1OptionalAttributes + | CloudEventV03 + | CloudEventV03Attributes + | CloudEventV03OptionalAttributes, + ): CloudEvent { + return new CloudEvent(Object.assign({}, this.toJSON(), options) as CloudEvent); + } } diff --git a/src/event/v03/cloudevent.ts b/src/event/v03/cloudevent.ts index 35525cb..9d4b5d6 100644 --- a/src/event/v03/cloudevent.ts +++ b/src/event/v03/cloudevent.ts @@ -25,7 +25,7 @@ export interface CloudEventV03 extends CloudEventV03Attributes { specversion: string; } -export interface CloudEventV03Attributes { +export interface CloudEventV03Attributes extends CloudEventV03OptionalAttributes { /** * [REQUIRED] Identifies the context in which an event happened. Often this * will include information such as the type of the event source, the @@ -57,7 +57,9 @@ export interface CloudEventV03Attributes { * @example com.example.object.delete.v2 */ type: string; +} +export interface CloudEventV03OptionalAttributes { /** * The following fields are optional. */ diff --git a/src/event/v1/cloudevent.ts b/src/event/v1/cloudevent.ts index b2b1408..f9f2729 100644 --- a/src/event/v1/cloudevent.ts +++ b/src/event/v1/cloudevent.ts @@ -25,7 +25,7 @@ export interface CloudEventV1 extends CloudEventV1Attributes { specversion: string; } -export interface CloudEventV1Attributes { +export interface CloudEventV1Attributes extends CloudEventV1OptionalAttributes { /** * [REQUIRED] Identifies the context in which an event happened. Often this * will include information such as the type of the event source, the @@ -58,7 +58,9 @@ export interface CloudEventV1Attributes { * @example com.example.object.delete.v2 */ type: string; +} +export interface CloudEventV1OptionalAttributes { /** * The following fields are optional. */ diff --git a/test/integration/cloud_event_test.ts b/test/integration/cloud_event_test.ts index 5c91b9b..6ea0645 100644 --- a/test/integration/cloud_event_test.ts +++ b/test/integration/cloud_event_test.ts @@ -139,7 +139,7 @@ describe("A 0.3 CloudEvent", () => { }); it("can be constructed with a datacontentencoding", () => { - const ce = new CloudEvent({ datacontentencoding: "Base64", ...v03fixture }); + const ce = new CloudEvent({ datacontentencoding: "Base64", ...v03fixture, data: "SSB3YXMgZnVubnkg8J+Ygg==" }); expect(ce.datacontentencoding).to.equal("Base64"); }); diff --git a/test/integration/http_binding_03.ts b/test/integration/http_binding_03.ts index 8d951c9..aae7f3b 100644 --- a/test/integration/http_binding_03.ts +++ b/test/integration/http_binding_03.ts @@ -38,9 +38,9 @@ const cloudevent = new CloudEvent({ dataschema: "", datacontentencoding: "", data_base64: "", + [ext1Name]: ext1Value, + [ext2Name]: ext2Value, }); -cloudevent[ext1Name] = ext1Value; -cloudevent[ext2Name] = ext2Value; const cebase64 = new CloudEvent({ specversion: Version.V03, @@ -51,9 +51,9 @@ const cebase64 = new CloudEvent({ time, schemaurl, data: dataBase64, + [ext1Name]: ext1Value, + [ext2Name]: ext2Value, }); -cebase64[ext1Name] = ext1Value; -cebase64[ext2Name] = ext2Value; const webhook = "https://cloudevents.io/webhook"; const httpcfg = { diff --git a/test/integration/http_binding_1.ts b/test/integration/http_binding_1.ts index b38e10a..0a88a64 100644 --- a/test/integration/http_binding_1.ts +++ b/test/integration/http_binding_1.ts @@ -25,7 +25,7 @@ const ext1Value = "foobar"; const ext2Name = "extension2"; const ext2Value = "acme"; -const cloudevent = new CloudEvent({ +let cloudevent = new CloudEvent({ specversion: Version.V1, type, source, @@ -35,8 +35,7 @@ const cloudevent = new CloudEvent({ dataschema, data, }); -cloudevent[ext1Name] = ext1Value; -cloudevent[ext2Name] = ext2Value; +cloudevent = cloudevent.cloneWith({ [ext1Name]: ext1Value, [ext2Name]: ext2Value }); const dataString = ")(*~^my data for ce#@#$%"; @@ -81,9 +80,9 @@ describe("HTTP Transport Binding - Version 1.0", () => { source, datacontenttype: "text/plain", data: bindata, + [ext1Name]: ext1Value, + [ext2Name]: ext2Value, }); - binevent[ext1Name] = ext1Value; - binevent[ext2Name] = ext2Value; return emitStructured(binevent, httpcfg).then((response: AxiosResponse) => { expect(JSON.parse(response.config.data).data_base64).to.equal(expected); @@ -96,9 +95,9 @@ describe("HTTP Transport Binding - Version 1.0", () => { source, datacontenttype: "text/plain", data: Uint32Array.from(dataString as string, (c) => c.codePointAt(0) as number), + [ext1Name]: ext1Value, + [ext2Name]: ext2Value, }); - binevent[ext1Name] = ext1Value; - binevent[ext2Name] = ext2Value; return emitStructured(binevent, httpcfg).then((response: AxiosResponse) => { expect(JSON.parse(response.config.data)).to.have.property("data_base64"); diff --git a/test/integration/spec_03_tests.ts b/test/integration/spec_03_tests.ts index a271697..c073275 100644 --- a/test/integration/spec_03_tests.ts +++ b/test/integration/spec_03_tests.ts @@ -13,7 +13,7 @@ const data = { }; const subject = "subject-x0"; -const cloudevent = new CloudEvent({ +let cloudevent = new CloudEvent({ specversion: Version.V03, id, source, @@ -46,8 +46,13 @@ describe("CloudEvents Spec v0.3", () => { describe("OPTIONAL Attributes", () => { it("Should have 'datacontentencoding'", () => { - cloudevent.datacontentencoding = Constants.ENCODING_BASE64; + cloudevent = cloudevent.cloneWith({ + datacontentencoding: Constants.ENCODING_BASE64, + data: "SSB3YXMgZnVubnkg8J+Ygg==", + }); expect(cloudevent.datacontentencoding).to.equal(Constants.ENCODING_BASE64); + + cloudevent = cloudevent.cloneWith({ datacontentencoding: undefined, data: data }); }); it("Should have 'datacontenttype'", () => { @@ -71,101 +76,101 @@ describe("CloudEvents Spec v0.3", () => { }); it("Should have the 'extension1'", () => { - cloudevent.extension1 = "value1"; + cloudevent = cloudevent.cloneWith({ extension1: "value1" }); expect(cloudevent.extension1).to.equal("value1"); }); }); describe("The Constraints check", () => { describe("'id'", () => { - it("should throw an error when is absent", () => { - delete cloudevent.id; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.id = id; + it("should throw an error when trying to remove", () => { + expect(() => { + delete cloudevent.id; + }).to.throw(TypeError); }); - it("should throw an error when is empty", () => { - cloudevent.id = ""; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.id = id; + it("defaut ID create when an empty string", () => { + cloudevent = cloudevent.cloneWith({ id: "" }); + expect(cloudevent.id.length).to.be.greaterThan(0); }); }); describe("'source'", () => { - it("should throw an error when is absent", () => { - delete cloudevent.source; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.source = source; + it("should throw an error when trying to remove", () => { + expect(() => { + delete cloudevent.source; + }).to.throw(TypeError); }); }); describe("'specversion'", () => { - it("should throw an error when is absent", () => { - delete cloudevent.specversion; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.specversion = Version.V03; + it("should throw an error when trying to remove", () => { + expect(() => { + delete cloudevent.specversion; + }).to.throw(TypeError); }); }); describe("'type'", () => { - it("should throw an error when is absent", () => { - delete cloudevent.type; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.type = type; + it("should throw an error when trying to remove", () => { + expect(() => { + delete cloudevent.type; + }).to.throw(TypeError); }); it("should throw an error when is an empty string", () => { - cloudevent.type = ""; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.type = type; + expect(() => { + cloudevent.cloneWith({ type: "" }); + }).to.throw(ValidationError, "invalid payload"); }); it("must be a non-empty string", () => { - cloudevent.type = type; + cloudevent.cloneWith({ type: type }); expect(cloudevent.type).to.equal(type); }); }); describe("'datacontentencoding'", () => { it("should throw an error when is a unsupported encoding", () => { - cloudevent.data = "Y2xvdWRldmVudHMK"; - cloudevent.datacontentencoding = Mode.BINARY; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - delete cloudevent.datacontentencoding; - cloudevent.data = data; + expect(() => { + cloudevent.cloneWith({ data: "Y2xvdWRldmVudHMK", datacontentencoding: Mode.BINARY }); + }).to.throw(ValidationError, "invalid payload"); + + cloudevent.cloneWith({ data: data, datacontentencoding: undefined }); }); it("should throw an error when 'data' does not carry base64", () => { - cloudevent.data = "no base 64 value"; - cloudevent.datacontentencoding = Constants.ENCODING_BASE64; - cloudevent.datacontenttype = "text/plain"; + expect(() => { + cloudevent.cloneWith({ + data: "no base 64 value", + datacontentencoding: Constants.ENCODING_BASE64, + datacontenttype: "text/plain", + }); + }).to.throw(ValidationError, "invalid payload"); - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - - delete cloudevent.datacontentencoding; - cloudevent.data = data; + cloudevent.cloneWith({ + data: data, + datacontentencoding: undefined, + }); }); it("should accept when 'data' is a string", () => { - cloudevent.data = "Y2xvdWRldmVudHMK"; - cloudevent.datacontentencoding = Constants.ENCODING_BASE64; + cloudevent.cloneWith({ data: "Y2xvdWRldmVudHMK", datacontentencoding: Constants.ENCODING_BASE64 }); expect(cloudevent.validate()).to.be.true; - delete cloudevent.datacontentencoding; - cloudevent.data = data; + cloudevent.cloneWith({ data: data, datacontentencoding: undefined }); }); }); describe("'data'", () => { it("should maintain the type of data when no data content type", () => { - delete cloudevent.datacontenttype; + cloudevent = cloudevent.cloneWith({ datacontenttype: undefined }); cloudevent.data = JSON.stringify(data); expect(typeof cloudevent.data).to.equal("string"); - cloudevent.datacontenttype = Constants.MIME_JSON; }); it("should convert data with stringified json to a json object", () => { - cloudevent.datacontenttype = Constants.MIME_JSON; + cloudevent = cloudevent.cloneWith({ datacontenttype: Constants.MIME_JSON }); cloudevent.data = JSON.stringify(data); expect(cloudevent.data).to.deep.equal(data); }); @@ -173,14 +178,15 @@ describe("CloudEvents Spec v0.3", () => { describe("'subject'", () => { it("should throw an error when is an empty string", () => { - cloudevent.subject = ""; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.subject = subject; + expect(() => { + cloudevent.cloneWith({ subject: "" }); + }).to.throw(ValidationError); }); }); describe("'time'", () => { it("must adhere to the format specified in RFC 3339", () => { + cloudevent = cloudevent.cloneWith({ time: time }); expect(cloudevent.time).to.equal(time.toISOString()); }); }); diff --git a/test/integration/spec_1_tests.ts b/test/integration/spec_1_tests.ts index ae358e8..47dd1b5 100644 --- a/test/integration/spec_1_tests.ts +++ b/test/integration/spec_1_tests.ts @@ -14,7 +14,7 @@ const data = { }; const subject = "subject-x0"; -const cloudevent = new CloudEvent({ +let cloudevent = new CloudEvent({ specversion: Version.V1, id, source, @@ -65,31 +65,26 @@ describe("CloudEvents Spec v1.0", () => { describe("Extensions Constraints", () => { it("should be ok when type is 'boolean'", () => { - cloudevent["ext-boolean"] = true; - expect(cloudevent.validate()).to.equal(true); + expect(cloudevent.cloneWith({ "ext-boolean": true }).validate()).to.equal(true); }); it("should be ok when type is 'integer'", () => { - cloudevent["ext-integer"] = 2019; - expect(cloudevent.validate()).to.equal(true); + expect(cloudevent.cloneWith({ "ext-integer": 2019 }).validate()).to.equal(true); }); it("should be ok when type is 'string'", () => { - cloudevent["ext-string"] = "an-string"; - expect(cloudevent.validate()).to.equal(true); + expect(cloudevent.cloneWith({ "ext-string": "an-string" }).validate()).to.equal(true); }); it("should be ok when type is 'Uint32Array' for 'Binary'", () => { const myBinary = new Uint32Array(2019); - cloudevent["ext-binary"] = myBinary; - expect(cloudevent.validate()).to.equal(true); + expect(cloudevent.cloneWith({ "ext-binary": myBinary }).validate()).to.equal(true); }); // URI it("should be ok when type is 'Date' for 'Timestamp'", () => { const myDate = new Date(); - cloudevent["ext-date"] = myDate; - expect(cloudevent.validate()).to.equal(true); + expect(cloudevent.cloneWith({ "ext-date": myDate }).validate()).to.equal(true); }); // even though the spec doesn't allow object types for @@ -97,73 +92,59 @@ describe("CloudEvents Spec v1.0", () => { // is transmitted across the wire, this value will be // converted to JSON it("should be ok when the type is an object", () => { - cloudevent["object-extension"] = { some: "object" }; - expect(cloudevent.validate()).to.equal(true); + expect(cloudevent.cloneWith({ "object-extension": { some: "object" } }).validate()).to.equal(true); }); }); describe("The Constraints check", () => { describe("'id'", () => { - it("should throw an error when is absent", () => { - delete cloudevent.id; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.id = id; + it("should throw an error when trying to remove", () => { + expect(() => { + delete cloudevent.id; + }).to.throw(TypeError); }); - it("should throw an error when is empty", () => { - cloudevent.id = ""; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.id = id; + it("defaut ID create when an empty string", () => { + cloudevent = cloudevent.cloneWith({ id: "" }); + expect(cloudevent.id.length).to.be.greaterThan(0); }); }); describe("'source'", () => { - it("should throw an error when is absent", () => { - delete cloudevent.source; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.source = source; + it("should throw an error when trying to remove", () => { + expect(() => { + delete cloudevent.source; + }).to.throw(TypeError); }); }); describe("'specversion'", () => { - it("should throw an error when is absent", () => { - delete cloudevent.specversion; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.specversion = Version.V1; - }); - - it("should throw an error when is empty", () => { - cloudevent.specversion = "" as Version; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.specversion = Version.V1; + it("should throw an error when trying to remove", () => { + expect(() => { + delete cloudevent.specversion; + }).to.throw(TypeError); }); }); describe("'type'", () => { - it("should throw an error when is absent", () => { - delete cloudevent.type; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.type = type; - }); - - it("should throw an error when is an empty string", () => { - cloudevent.type = ""; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.type = type; + it("should throw an error when trying to remove", () => { + expect(() => { + delete cloudevent.type; + }).to.throw(TypeError); }); }); describe("'subject'", () => { it("should throw an error when is an empty string", () => { - cloudevent.subject = ""; - expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload"); - cloudevent.subject = type; + expect(() => { + cloudevent.cloneWith({ subject: "" }); + }).to.throw(ValidationError, "invalid payload"); }); }); describe("'time'", () => { it("must adhere to the format specified in RFC 3339", () => { - cloudevent.time = time; + cloudevent = cloudevent.cloneWith({ time: time }); expect(cloudevent.time).to.equal(time.toISOString()); }); }); @@ -176,16 +157,15 @@ describe("CloudEvents Spec v1.0", () => { it("should maintain the type of data when no data content type", () => { const dct = cloudevent.datacontenttype; - delete cloudevent.datacontenttype; + cloudevent = cloudevent.cloneWith({ datacontenttype: undefined }); cloudevent.data = JSON.stringify(data); expect(typeof cloudevent.data).to.equal("string"); - cloudevent.datacontenttype = dct; + cloudevent = cloudevent.cloneWith({ datacontenttype: dct }); }); it("should convert data with stringified json to a json object", () => { - cloudevent.datacontenttype = Constants.MIME_JSON; - cloudevent.data = JSON.stringify(data); + cloudevent = cloudevent.cloneWith({ datacontenttype: Constants.MIME_JSON, data: JSON.stringify(data) }); expect(cloudevent.data).to.deep.equal(data); }); @@ -194,13 +174,10 @@ describe("CloudEvents Spec v1.0", () => { const dataBinary = Uint32Array.from(dataString, (c) => c.codePointAt(0) as number); const expected = asBase64(dataBinary); - const olddct = cloudevent.datacontenttype; - cloudevent.datacontenttype = "text/plain"; - cloudevent.data = dataBinary; + cloudevent = cloudevent.cloneWith({ datacontenttype: "text/plain", data: dataBinary }); + expect(cloudevent.data_base64).to.equal(expected); - - cloudevent.datacontenttype = olddct; }); }); });