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 <lholmqui@redhat.com>
This commit is contained in:
Lucas Holmquist 2020-07-13 15:15:32 -04:00 committed by GitHub
parent dca2811627
commit c7a84772d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 131 additions and 123 deletions

View File

@ -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);
}
}

View File

@ -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.
*/

View File

@ -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.
*/

View File

@ -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");
});

View File

@ -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 = {

View File

@ -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");

View File

@ -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());
});
});

View File

@ -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;
});
});
});