Skip to content

Commit c7a8477

Browse files
authored
feat(src): A CloudEvent should be readonly but provide a way to augment itself. (cloudevents#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>
1 parent dca2811 commit c7a8477

File tree

8 files changed

+132
-124
lines changed

8 files changed

+132
-124
lines changed

src/event/cloudevent.ts

+24-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { v4 as uuidv4 } from "uuid";
22

3-
import { CloudEventV1, validateV1, CloudEventV1Attributes } from "./v1";
4-
import { CloudEventV03, validateV03, CloudEventV03Attributes } from "./v03";
3+
import { CloudEventV1, validateV1, CloudEventV1Attributes, CloudEventV1OptionalAttributes } from "./v1";
4+
import { CloudEventV03, validateV03, CloudEventV03Attributes, CloudEventV03OptionalAttributes } from "./v03";
55
import { ValidationError, isBinary, asBase64 } from "./validation";
66
import CONSTANTS from "../constants";
77
import { isString } from "util";
@@ -98,6 +98,10 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 {
9898
for (const [key, value] of Object.entries(properties)) {
9999
this[key] = value;
100100
}
101+
102+
this.validate();
103+
104+
Object.freeze(this);
101105
}
102106

103107
get time(): string | Date {
@@ -165,4 +169,22 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 {
165169
}
166170
}
167171
}
172+
173+
/**
174+
* Clone a CloudEvent with new/update attributes
175+
* @param {object} options attributes to augment the CloudEvent with
176+
* @throws if the CloudEvent does not conform to the schema
177+
* @return {CloudEvent} returns a new CloudEvent
178+
*/
179+
public cloneWith(
180+
options:
181+
| CloudEventV1
182+
| CloudEventV1Attributes
183+
| CloudEventV1OptionalAttributes
184+
| CloudEventV03
185+
| CloudEventV03Attributes
186+
| CloudEventV03OptionalAttributes,
187+
): CloudEvent {
188+
return new CloudEvent(Object.assign({}, this.toJSON(), options) as CloudEvent);
189+
}
168190
}

src/event/v03/cloudevent.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface CloudEventV03 extends CloudEventV03Attributes {
2525
specversion: string;
2626
}
2727

28-
export interface CloudEventV03Attributes {
28+
export interface CloudEventV03Attributes extends CloudEventV03OptionalAttributes {
2929
/**
3030
* [REQUIRED] Identifies the context in which an event happened. Often this
3131
* will include information such as the type of the event source, the
@@ -57,7 +57,9 @@ export interface CloudEventV03Attributes {
5757
* @example com.example.object.delete.v2
5858
*/
5959
type: string;
60+
}
6061

62+
export interface CloudEventV03OptionalAttributes {
6163
/**
6264
* The following fields are optional.
6365
*/

src/event/v1/cloudevent.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface CloudEventV1 extends CloudEventV1Attributes {
2525
specversion: string;
2626
}
2727

28-
export interface CloudEventV1Attributes {
28+
export interface CloudEventV1Attributes extends CloudEventV1OptionalAttributes {
2929
/**
3030
* [REQUIRED] Identifies the context in which an event happened. Often this
3131
* will include information such as the type of the event source, the
@@ -58,7 +58,9 @@ export interface CloudEventV1Attributes {
5858
* @example com.example.object.delete.v2
5959
*/
6060
type: string;
61+
}
6162

63+
export interface CloudEventV1OptionalAttributes {
6264
/**
6365
* The following fields are optional.
6466
*/

test/integration/cloud_event_test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ describe("A 0.3 CloudEvent", () => {
139139
});
140140

141141
it("can be constructed with a datacontentencoding", () => {
142-
const ce = new CloudEvent({ datacontentencoding: "Base64", ...v03fixture });
142+
const ce = new CloudEvent({ datacontentencoding: "Base64", ...v03fixture, data: "SSB3YXMgZnVubnkg8J+Ygg==" });
143143
expect(ce.datacontentencoding).to.equal("Base64");
144144
});
145145

test/integration/http_binding_03.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ const cloudevent = new CloudEvent({
3838
dataschema: "",
3939
datacontentencoding: "",
4040
data_base64: "",
41+
[ext1Name]: ext1Value,
42+
[ext2Name]: ext2Value,
4143
});
42-
cloudevent[ext1Name] = ext1Value;
43-
cloudevent[ext2Name] = ext2Value;
4444

4545
const cebase64 = new CloudEvent({
4646
specversion: Version.V03,
@@ -51,9 +51,9 @@ const cebase64 = new CloudEvent({
5151
time,
5252
schemaurl,
5353
data: dataBase64,
54+
[ext1Name]: ext1Value,
55+
[ext2Name]: ext2Value,
5456
});
55-
cebase64[ext1Name] = ext1Value;
56-
cebase64[ext2Name] = ext2Value;
5757

5858
const webhook = "https://cloudevents.io/webhook";
5959
const httpcfg = {

test/integration/http_binding_1.ts

+6-7
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const ext1Value = "foobar";
2525
const ext2Name = "extension2";
2626
const ext2Value = "acme";
2727

28-
const cloudevent = new CloudEvent({
28+
let cloudevent = new CloudEvent({
2929
specversion: Version.V1,
3030
type,
3131
source,
@@ -35,8 +35,7 @@ const cloudevent = new CloudEvent({
3535
dataschema,
3636
data,
3737
});
38-
cloudevent[ext1Name] = ext1Value;
39-
cloudevent[ext2Name] = ext2Value;
38+
cloudevent = cloudevent.cloneWith({ [ext1Name]: ext1Value, [ext2Name]: ext2Value });
4039

4140
const dataString = ")(*~^my data for ce#@#$%";
4241

@@ -81,9 +80,9 @@ describe("HTTP Transport Binding - Version 1.0", () => {
8180
source,
8281
datacontenttype: "text/plain",
8382
data: bindata,
83+
[ext1Name]: ext1Value,
84+
[ext2Name]: ext2Value,
8485
});
85-
binevent[ext1Name] = ext1Value;
86-
binevent[ext2Name] = ext2Value;
8786

8887
return emitStructured(binevent, httpcfg).then((response: AxiosResponse) => {
8988
expect(JSON.parse(response.config.data).data_base64).to.equal(expected);
@@ -96,9 +95,9 @@ describe("HTTP Transport Binding - Version 1.0", () => {
9695
source,
9796
datacontenttype: "text/plain",
9897
data: Uint32Array.from(dataString as string, (c) => c.codePointAt(0) as number),
98+
[ext1Name]: ext1Value,
99+
[ext2Name]: ext2Value,
99100
});
100-
binevent[ext1Name] = ext1Value;
101-
binevent[ext2Name] = ext2Value;
102101

103102
return emitStructured(binevent, httpcfg).then((response: AxiosResponse) => {
104103
expect(JSON.parse(response.config.data)).to.have.property("data_base64");

test/integration/spec_03_tests.ts

+56-50
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const data = {
1313
};
1414
const subject = "subject-x0";
1515

16-
const cloudevent = new CloudEvent({
16+
let cloudevent = new CloudEvent({
1717
specversion: Version.V03,
1818
id,
1919
source,
@@ -46,8 +46,13 @@ describe("CloudEvents Spec v0.3", () => {
4646

4747
describe("OPTIONAL Attributes", () => {
4848
it("Should have 'datacontentencoding'", () => {
49-
cloudevent.datacontentencoding = Constants.ENCODING_BASE64;
49+
cloudevent = cloudevent.cloneWith({
50+
datacontentencoding: Constants.ENCODING_BASE64,
51+
data: "SSB3YXMgZnVubnkg8J+Ygg==",
52+
});
5053
expect(cloudevent.datacontentencoding).to.equal(Constants.ENCODING_BASE64);
54+
55+
cloudevent = cloudevent.cloneWith({ datacontentencoding: undefined, data: data });
5156
});
5257

5358
it("Should have 'datacontenttype'", () => {
@@ -71,116 +76,117 @@ describe("CloudEvents Spec v0.3", () => {
7176
});
7277

7378
it("Should have the 'extension1'", () => {
74-
cloudevent.extension1 = "value1";
79+
cloudevent = cloudevent.cloneWith({ extension1: "value1" });
7580
expect(cloudevent.extension1).to.equal("value1");
7681
});
7782
});
7883

7984
describe("The Constraints check", () => {
8085
describe("'id'", () => {
81-
it("should throw an error when is absent", () => {
82-
delete cloudevent.id;
83-
expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload");
84-
cloudevent.id = id;
86+
it("should throw an error when trying to remove", () => {
87+
expect(() => {
88+
delete cloudevent.id;
89+
}).to.throw(TypeError);
8590
});
8691

87-
it("should throw an error when is empty", () => {
88-
cloudevent.id = "";
89-
expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload");
90-
cloudevent.id = id;
92+
it("defaut ID create when an empty string", () => {
93+
cloudevent = cloudevent.cloneWith({ id: "" });
94+
expect(cloudevent.id.length).to.be.greaterThan(0);
9195
});
9296
});
9397

9498
describe("'source'", () => {
95-
it("should throw an error when is absent", () => {
96-
delete cloudevent.source;
97-
expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload");
98-
cloudevent.source = source;
99+
it("should throw an error when trying to remove", () => {
100+
expect(() => {
101+
delete cloudevent.source;
102+
}).to.throw(TypeError);
99103
});
100104
});
101105

102106
describe("'specversion'", () => {
103-
it("should throw an error when is absent", () => {
104-
delete cloudevent.specversion;
105-
expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload");
106-
cloudevent.specversion = Version.V03;
107+
it("should throw an error when trying to remove", () => {
108+
expect(() => {
109+
delete cloudevent.specversion;
110+
}).to.throw(TypeError);
107111
});
108112
});
109113

110114
describe("'type'", () => {
111-
it("should throw an error when is absent", () => {
112-
delete cloudevent.type;
113-
expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload");
114-
cloudevent.type = type;
115+
it("should throw an error when trying to remove", () => {
116+
expect(() => {
117+
delete cloudevent.type;
118+
}).to.throw(TypeError);
115119
});
116120

117121
it("should throw an error when is an empty string", () => {
118-
cloudevent.type = "";
119-
expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload");
120-
cloudevent.type = type;
122+
expect(() => {
123+
cloudevent.cloneWith({ type: "" });
124+
}).to.throw(ValidationError, "invalid payload");
121125
});
122126

123127
it("must be a non-empty string", () => {
124-
cloudevent.type = type;
128+
cloudevent.cloneWith({ type: type });
125129
expect(cloudevent.type).to.equal(type);
126130
});
127131
});
128132

129133
describe("'datacontentencoding'", () => {
130134
it("should throw an error when is a unsupported encoding", () => {
131-
cloudevent.data = "Y2xvdWRldmVudHMK";
132-
cloudevent.datacontentencoding = Mode.BINARY;
133-
expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload");
134-
delete cloudevent.datacontentencoding;
135-
cloudevent.data = data;
135+
expect(() => {
136+
cloudevent.cloneWith({ data: "Y2xvdWRldmVudHMK", datacontentencoding: Mode.BINARY });
137+
}).to.throw(ValidationError, "invalid payload");
138+
139+
cloudevent.cloneWith({ data: data, datacontentencoding: undefined });
136140
});
137141

138142
it("should throw an error when 'data' does not carry base64", () => {
139-
cloudevent.data = "no base 64 value";
140-
cloudevent.datacontentencoding = Constants.ENCODING_BASE64;
141-
cloudevent.datacontenttype = "text/plain";
142-
143-
expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload");
144-
145-
delete cloudevent.datacontentencoding;
146-
cloudevent.data = data;
143+
expect(() => {
144+
cloudevent.cloneWith({
145+
data: "no base 64 value",
146+
datacontentencoding: Constants.ENCODING_BASE64,
147+
datacontenttype: "text/plain",
148+
});
149+
}).to.throw(ValidationError, "invalid payload");
150+
151+
cloudevent.cloneWith({
152+
data: data,
153+
datacontentencoding: undefined,
154+
});
147155
});
148156

149157
it("should accept when 'data' is a string", () => {
150-
cloudevent.data = "Y2xvdWRldmVudHMK";
151-
cloudevent.datacontentencoding = Constants.ENCODING_BASE64;
158+
cloudevent.cloneWith({ data: "Y2xvdWRldmVudHMK", datacontentencoding: Constants.ENCODING_BASE64 });
152159
expect(cloudevent.validate()).to.be.true;
153-
delete cloudevent.datacontentencoding;
154-
cloudevent.data = data;
160+
cloudevent.cloneWith({ data: data, datacontentencoding: undefined });
155161
});
156162
});
157163

158164
describe("'data'", () => {
159165
it("should maintain the type of data when no data content type", () => {
160-
delete cloudevent.datacontenttype;
166+
cloudevent = cloudevent.cloneWith({ datacontenttype: undefined });
161167
cloudevent.data = JSON.stringify(data);
162168

163169
expect(typeof cloudevent.data).to.equal("string");
164-
cloudevent.datacontenttype = Constants.MIME_JSON;
165170
});
166171

167172
it("should convert data with stringified json to a json object", () => {
168-
cloudevent.datacontenttype = Constants.MIME_JSON;
173+
cloudevent = cloudevent.cloneWith({ datacontenttype: Constants.MIME_JSON });
169174
cloudevent.data = JSON.stringify(data);
170175
expect(cloudevent.data).to.deep.equal(data);
171176
});
172177
});
173178

174179
describe("'subject'", () => {
175180
it("should throw an error when is an empty string", () => {
176-
cloudevent.subject = "";
177-
expect(cloudevent.validate.bind(cloudevent)).to.throw(ValidationError, "invalid payload");
178-
cloudevent.subject = subject;
181+
expect(() => {
182+
cloudevent.cloneWith({ subject: "" });
183+
}).to.throw(ValidationError);
179184
});
180185
});
181186

182187
describe("'time'", () => {
183188
it("must adhere to the format specified in RFC 3339", () => {
189+
cloudevent = cloudevent.cloneWith({ time: time });
184190
expect(cloudevent.time).to.equal(time.toISOString());
185191
});
186192
});

0 commit comments

Comments
 (0)