Skip to content

Commit d941e2d

Browse files
authored
feat: use generic type for CloudEvent data (#446)
Instead of using a big union of types, use a generic type for event data. Fixes: #445 Signed-off-by: Lance Ball <lball@redhat.com>
1 parent 52ea7de commit d941e2d

File tree

11 files changed

+72
-57
lines changed

11 files changed

+72
-57
lines changed

src/event/cloudevent.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { v4 as uuidv4 } from "uuid";
77
import { Emitter } from "..";
88

9-
import { CloudEventV1, CloudEventV1Attributes, CloudEventV1OptionalAttributes } from "./interfaces";
9+
import { CloudEventV1 } from "./interfaces";
1010
import { validateCloudEvent } from "./spec";
1111
import { ValidationError, isBinary, asBase64, isValidType } from "./validation";
1212

@@ -23,7 +23,7 @@ export const enum Version {
2323
* interoperability across services, platforms and systems.
2424
* @see https://github.com/cloudevents/spec/blob/v1.0/spec.md
2525
*/
26-
export class CloudEvent implements CloudEventV1 {
26+
export class CloudEvent<T = undefined> implements CloudEventV1<T> {
2727
id: string;
2828
type: string;
2929
source: string;
@@ -32,7 +32,7 @@ export class CloudEvent implements CloudEventV1 {
3232
dataschema?: string;
3333
subject?: string;
3434
time?: string;
35-
#_data?: Record<string, unknown | string | number | boolean> | string | number | boolean | null | unknown;
35+
#_data?: T;
3636
data_base64?: string;
3737

3838
// Extensions should not exist as it's own object, but instead
@@ -51,7 +51,7 @@ export class CloudEvent implements CloudEventV1 {
5151
* @param {object} event the event properties
5252
* @param {boolean?} strict whether to perform event validation when creating the object - default: true
5353
*/
54-
constructor(event: CloudEventV1 | CloudEventV1Attributes, strict = true) {
54+
constructor(event: Partial<CloudEventV1<T>>, strict = true) {
5555
// copy the incoming event so that we can delete properties as we go
5656
// everything left after we have deleted know properties becomes an extension
5757
const properties = { ...event };
@@ -62,10 +62,10 @@ export class CloudEvent implements CloudEventV1 {
6262
this.time = properties.time || new Date().toISOString();
6363
delete properties.time;
6464

65-
this.type = properties.type;
65+
this.type = properties.type as string;
6666
delete (properties as any).type;
6767

68-
this.source = properties.source;
68+
this.source = properties.source as string;
6969
delete (properties as any).source;
7070

7171
this.specversion = (properties.specversion as Version) || Version.V1;
@@ -126,13 +126,13 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`);
126126
Object.freeze(this);
127127
}
128128

129-
get data(): unknown {
129+
get data(): T | undefined {
130130
return this.#_data;
131131
}
132132

133-
set data(value: unknown) {
133+
set data(value: T | undefined) {
134134
if (isBinary(value)) {
135-
this.data_base64 = asBase64(value as Uint32Array);
135+
this.data_base64 = asBase64(value);
136136
}
137137
this.#_data = value;
138138
}
@@ -184,16 +184,29 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`);
184184

185185
/**
186186
* Clone a CloudEvent with new/update attributes
187-
* @param {object} options attributes to augment the CloudEvent with
187+
* @param {object} options attributes to augment the CloudEvent with an `data` property
188+
* @param {boolean} strict whether or not to use strict validation when cloning (default: true)
189+
* @throws if the CloudEvent does not conform to the schema
190+
* @return {CloudEvent} returns a new CloudEvent<T>
191+
*/
192+
public cloneWith(options: Partial<Exclude<CloudEventV1<never>, "data">>, strict?: boolean): CloudEvent<T>;
193+
/**
194+
* Clone a CloudEvent with new/update attributes
195+
* @param {object} options attributes to augment the CloudEvent with a `data` property
196+
* @param {boolean} strict whether or not to use strict validation when cloning (default: true)
197+
* @throws if the CloudEvent does not conform to the schema
198+
* @return {CloudEvent} returns a new CloudEvent<D>
199+
*/
200+
public cloneWith<D>(options: Partial<CloudEvent<D>>, strict?: boolean): CloudEvent<D>;
201+
/**
202+
* Clone a CloudEvent with new/update attributes
203+
* @param {object} options attributes to augment the CloudEvent
188204
* @param {boolean} strict whether or not to use strict validation when cloning (default: true)
189205
* @throws if the CloudEvent does not conform to the schema
190206
* @return {CloudEvent} returns a new CloudEvent
191207
*/
192-
public cloneWith(
193-
options: CloudEventV1 | CloudEventV1Attributes | CloudEventV1OptionalAttributes,
194-
strict = true,
195-
): CloudEvent {
196-
return new CloudEvent(Object.assign({}, this.toJSON(), options) as CloudEvent, strict);
208+
public cloneWith<D>(options: Partial<CloudEventV1<D>>, strict = true): CloudEvent<D | T> {
209+
return new CloudEvent(Object.assign({}, this.toJSON(), options), strict);
197210
}
198211

199212
/**

src/event/interfaces.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* The object interface for CloudEvents 1.0.
88
* @see https://github.com/cloudevents/spec/blob/v1.0/spec.md
99
*/
10-
export interface CloudEventV1 extends CloudEventV1Attributes {
10+
export interface CloudEventV1<T> extends CloudEventV1Attributes<T> {
1111
// REQUIRED Attributes
1212
/**
1313
* [REQUIRED] Identifies the event. Producers MUST ensure that `source` + `id`
@@ -30,7 +30,7 @@ export interface CloudEventV1 extends CloudEventV1Attributes {
3030
specversion: string;
3131
}
3232

33-
export interface CloudEventV1Attributes extends CloudEventV1OptionalAttributes {
33+
export interface CloudEventV1Attributes<T> extends CloudEventV1OptionalAttributes<T> {
3434
/**
3535
* [REQUIRED] Identifies the context in which an event happened. Often this
3636
* will include information such as the type of the event source, the
@@ -65,7 +65,7 @@ export interface CloudEventV1Attributes extends CloudEventV1OptionalAttributes {
6565
type: string;
6666
}
6767

68-
export interface CloudEventV1OptionalAttributes {
68+
export interface CloudEventV1OptionalAttributes<T> {
6969
/**
7070
* The following fields are optional.
7171
*/
@@ -126,7 +126,7 @@ export interface CloudEventV1OptionalAttributes {
126126
* specified by the datacontenttype attribute (e.g. application/json), and adheres
127127
* to the dataschema format when those respective attributes are present.
128128
*/
129-
data?: Record<string, unknown | string | number | boolean> | string | number | boolean | null | unknown;
129+
data?: T;
130130

131131
/**
132132
* [OPTIONAL] The event payload encoded as base64 data. This is used when the

src/event/spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ ajv.addFormat("js-date-time", function (dateTimeString) {
2121

2222
const isValidAgainstSchemaV1 = ajv.compile(schemaV1);
2323

24-
export function validateCloudEvent(event: CloudEventV1): boolean {
24+
export function validateCloudEvent<T>(event: CloudEventV1<T>): boolean {
2525
if (event.specversion === Version.V1) {
2626
if (!isValidAgainstSchemaV1(event)) {
2727
throw new ValidationError("invalid payload", isValidAgainstSchemaV1.errors);

src/event/validation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ export const isDefined = (v: unknown): boolean => v !== null && typeof v !== "un
3535

3636
export const isBoolean = (v: unknown): boolean => typeof v === "boolean";
3737
export const isInteger = (v: unknown): boolean => Number.isInteger(v as number);
38-
export const isDate = (v: unknown): boolean => v instanceof Date;
39-
export const isBinary = (v: unknown): boolean => v instanceof Uint32Array;
38+
export const isDate = (v: unknown): v is Date => v instanceof Date;
39+
export const isBinary = (v: unknown): v is Uint32Array => v instanceof Uint32Array;
4040

4141
export const isStringOrThrow = (v: unknown, t: Error): boolean =>
4242
isString(v)
@@ -75,7 +75,7 @@ export const isBuffer = (value: unknown): boolean => value instanceof Buffer;
7575

7676
export const asBuffer = (value: string | Buffer | Uint32Array): Buffer =>
7777
isBinary(value)
78-
? Buffer.from(value as string)
78+
? Buffer.from((value as unknown) as string)
7979
: isBuffer(value)
8080
? (value as Buffer)
8181
: (() => {

src/message/http/headers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const requiredHeaders = [
2424
* @param {CloudEvent} event a CloudEvent
2525
* @returns {Object} the headers that will be sent for the event
2626
*/
27-
export function headersFor(event: CloudEvent): Headers {
27+
export function headersFor<T>(event: CloudEvent<T>): Headers {
2828
const headers: Headers = {};
2929
let headerMap: Readonly<{ [key: string]: MappedParser }>;
3030
if (event.specversion === Version.V1) {

src/message/http/index.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@ import { JSONParser, MappedParser, Parser, parserByContentType } from "../../par
2525
* @param {CloudEvent} event The event to serialize
2626
* @returns {Message} a Message object with headers and body
2727
*/
28-
export function binary(event: CloudEvent): Message {
28+
export function binary<T>(event: CloudEvent<T>): Message {
2929
const contentType: Headers = { [CONSTANTS.HEADER_CONTENT_TYPE]: CONSTANTS.DEFAULT_CONTENT_TYPE };
3030
const headers: Headers = { ...contentType, ...headersFor(event) };
3131
let body = event.data;
3232
if (typeof event.data === "object" && !(event.data instanceof Uint32Array)) {
3333
// we'll stringify objects, but not binary data
34-
body = JSON.stringify(event.data);
34+
body = (JSON.stringify(event.data) as unknown) as T;
3535
}
3636
return {
3737
headers,
@@ -47,7 +47,7 @@ export function binary(event: CloudEvent): Message {
4747
* @param {CloudEvent} event the CloudEvent to be serialized
4848
* @returns {Message} a Message object with headers and body
4949
*/
50-
export function structured(event: CloudEvent): Message {
50+
export function structured<T>(event: CloudEvent<T>): Message {
5151
if (event.data_base64) {
5252
// The event's data is binary - delete it
5353
event = event.cloneWith({ data: undefined });
@@ -84,7 +84,7 @@ export function isEvent(message: Message): boolean {
8484
* @param {Message} message the incoming message
8585
* @return {CloudEvent} A new {CloudEvent} instance
8686
*/
87-
export function deserialize(message: Message): CloudEvent {
87+
export function deserialize<T>(message: Message): CloudEvent<T> {
8888
const cleanHeaders: Headers = sanitize(message.headers);
8989
const mode: Mode = getMode(cleanHeaders);
9090
const version = getVersion(mode, cleanHeaders, message.body);
@@ -133,7 +133,11 @@ function getVersion(mode: Mode, headers: Headers, body: string | Record<string,
133133
}
134134
} else {
135135
// structured mode - the version is in the body
136-
return typeof body === "string" ? JSON.parse(body).specversion : (body as CloudEvent).specversion;
136+
if (typeof body === "string") {
137+
return JSON.parse(body).specversion;
138+
} else {
139+
return (body as Record<string, string>).specversion;
140+
}
137141
}
138142
return Version.V1;
139143
}
@@ -147,7 +151,7 @@ function getVersion(mode: Mode, headers: Headers, body: string | Record<string,
147151
* @returns {CloudEvent} an instance of CloudEvent representing the incoming request
148152
* @throws {ValidationError} of the event does not conform to the spec
149153
*/
150-
function parseBinary(message: Message, version: Version): CloudEvent {
154+
function parseBinary<T>(message: Message, version: Version): CloudEvent<T> {
151155
const headers = { ...message.headers };
152156
let body = message.body;
153157

@@ -187,7 +191,7 @@ function parseBinary(message: Message, version: Version): CloudEvent {
187191
delete eventObj.datacontentencoding;
188192
}
189193

190-
return new CloudEvent({ ...eventObj, data: body } as CloudEventV1, false);
194+
return new CloudEvent<T>({ ...eventObj, data: body } as CloudEventV1<T>, false);
191195
}
192196

193197
/**
@@ -198,7 +202,7 @@ function parseBinary(message: Message, version: Version): CloudEvent {
198202
* @returns {CloudEvent} a new CloudEvent instance for the provided headers and payload
199203
* @throws {ValidationError} if the payload and header combination do not conform to the spec
200204
*/
201-
function parseStructured(message: Message, version: Version): CloudEvent {
205+
function parseStructured<T>(message: Message, version: Version): CloudEvent<T> {
202206
const payload = message.body;
203207
const headers = message.headers;
204208

@@ -240,5 +244,5 @@ function parseStructured(message: Message, version: Version): CloudEvent {
240244
delete eventObj.data_base64;
241245
delete eventObj.datacontentencoding;
242246
}
243-
return new CloudEvent(eventObj as CloudEventV1, false);
247+
return new CloudEvent<T>(eventObj as CloudEventV1<T>, false);
244248
}

src/message/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export enum Mode {
6161
* @interface
6262
*/
6363
export interface Serializer {
64-
(event: CloudEvent): Message;
64+
<T>(event: CloudEvent<T>): Message;
6565
}
6666

6767
/**
@@ -70,7 +70,7 @@ export interface Serializer {
7070
* @interface
7171
*/
7272
export interface Deserializer {
73-
(message: Message): CloudEvent;
73+
<T>(message: Message): CloudEvent<T>;
7474
}
7575

7676
/**

src/transport/emitter.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface Options {
2323
* @interface
2424
*/
2525
export interface EmitterFunction {
26-
(event: CloudEvent, options?: Options): Promise<unknown>;
26+
<T>(event: CloudEvent<T>, options?: Options): Promise<unknown>;
2727
}
2828

2929
/**
@@ -56,7 +56,7 @@ export function emitterFor(fn: TransportFunction, options = emitterDefaults): Em
5656
throw new TypeError("A TransportFunction is required");
5757
}
5858
const { binding, mode }: any = { ...emitterDefaults, ...options };
59-
return function emit(event: CloudEvent, opts?: Options): Promise<unknown> {
59+
return function emit<T>(event: CloudEvent<T>, opts?: Options): Promise<unknown> {
6060
opts = opts || {};
6161

6262
switch (mode) {
@@ -109,7 +109,7 @@ export class Emitter {
109109
* @param {boolean} ensureDelivery fail the promise if one listener fails
110110
* @return {void}
111111
*/
112-
static async emitEvent(event: CloudEvent, ensureDelivery = true): Promise<void> {
112+
static async emitEvent<T>(event: CloudEvent<T>, ensureDelivery = true): Promise<void> {
113113
if (!ensureDelivery) {
114114
// Ensure delivery is disabled so we don't wait for Promise
115115
Emitter.getInstance().emit("cloudevent", event);

test/integration/cloud_event_test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@ import fs from "fs";
88

99
import { expect } from "chai";
1010
import { CloudEvent, ValidationError, Version } from "../../src";
11-
import { CloudEventV1 } from "../../src/event/interfaces";
1211
import { asBase64 } from "../../src/event/validation";
1312

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

18-
const fixture: CloudEventV1 = {
17+
const fixture = {
1918
id,
2019
specversion: Version.V1,
2120
source,
@@ -34,17 +33,17 @@ describe("A CloudEvent", () => {
3433
});
3534

3635
it("Can be constructed with loose validation", () => {
37-
const ce = new CloudEvent({} as CloudEventV1, false);
36+
const ce = new CloudEvent({}, false);
3837
expect(ce).to.be.instanceOf(CloudEvent);
3938
});
4039

4140
it("Loosely validated events can be cloned", () => {
42-
const ce = new CloudEvent({} as CloudEventV1, false);
41+
const ce = new CloudEvent({}, false);
4342
expect(ce.cloneWith({}, false)).to.be.instanceOf(CloudEvent);
4443
});
4544

4645
it("Loosely validated events throw when validated", () => {
47-
const ce = new CloudEvent({} as CloudEventV1, false);
46+
const ce = new CloudEvent({}, false);
4847
expect(ce.validate).to.throw(ValidationError, "invalid payload");
4948
});
5049

test/integration/message_test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ describe("HTTP transport", () => {
105105
},
106106
};
107107
expect(HTTP.isEvent(message)).to.be.true;
108-
const event: CloudEvent = HTTP.toEvent(message);
108+
const event = HTTP.toEvent(message);
109109
expect(event.LUNCH).to.equal("tacos");
110110
expect(function () {
111111
event.validate();
@@ -124,7 +124,7 @@ describe("HTTP transport", () => {
124124
},
125125
};
126126
expect(HTTP.isEvent(message)).to.be.true;
127-
const event: CloudEvent = HTTP.toEvent(message);
127+
const event = HTTP.toEvent(message);
128128
expect(event.specversion).to.equal("11.8");
129129
expect(event.validate()).to.be.false;
130130
});
@@ -195,7 +195,7 @@ describe("HTTP transport", () => {
195195
});
196196

197197
describe("Specification version V1", () => {
198-
const fixture: CloudEvent = new CloudEvent({
198+
const fixture = new CloudEvent({
199199
specversion: Version.V1,
200200
id,
201201
type,
@@ -298,7 +298,7 @@ describe("HTTP transport", () => {
298298
});
299299

300300
describe("Specification version V03", () => {
301-
const fixture: CloudEvent = new CloudEvent({
301+
const fixture = new CloudEvent({
302302
specversion: Version.V03,
303303
id,
304304
type,

0 commit comments

Comments
 (0)