feat: use generic type for CloudEvent data (#446)

Instead of using a big union of types, use a generic type for event data.

Fixes: https://github.com/cloudevents/sdk-javascript/issues/445

Signed-off-by: Lance Ball <lball@redhat.com>
This commit is contained in:
Lance Ball 2021-11-29 17:03:12 -05:00 committed by GitHub
parent 52ea7de80d
commit d941e2d4d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 72 additions and 57 deletions

View File

@ -6,7 +6,7 @@
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { Emitter } from ".."; import { Emitter } from "..";
import { CloudEventV1, CloudEventV1Attributes, CloudEventV1OptionalAttributes } from "./interfaces"; import { CloudEventV1 } from "./interfaces";
import { validateCloudEvent } from "./spec"; import { validateCloudEvent } from "./spec";
import { ValidationError, isBinary, asBase64, isValidType } from "./validation"; import { ValidationError, isBinary, asBase64, isValidType } from "./validation";
@ -23,7 +23,7 @@ export const enum Version {
* interoperability across services, platforms and systems. * interoperability across services, platforms and systems.
* @see https://github.com/cloudevents/spec/blob/v1.0/spec.md * @see https://github.com/cloudevents/spec/blob/v1.0/spec.md
*/ */
export class CloudEvent implements CloudEventV1 { export class CloudEvent<T = undefined> implements CloudEventV1<T> {
id: string; id: string;
type: string; type: string;
source: string; source: string;
@ -32,7 +32,7 @@ export class CloudEvent implements CloudEventV1 {
dataschema?: string; dataschema?: string;
subject?: string; subject?: string;
time?: string; time?: string;
#_data?: Record<string, unknown | string | number | boolean> | string | number | boolean | null | unknown; #_data?: T;
data_base64?: string; data_base64?: string;
// Extensions should not exist as it's own object, but instead // Extensions should not exist as it's own object, but instead
@ -51,7 +51,7 @@ export class CloudEvent implements CloudEventV1 {
* @param {object} event the event properties * @param {object} event the event properties
* @param {boolean?} strict whether to perform event validation when creating the object - default: true * @param {boolean?} strict whether to perform event validation when creating the object - default: true
*/ */
constructor(event: CloudEventV1 | CloudEventV1Attributes, strict = true) { constructor(event: Partial<CloudEventV1<T>>, strict = true) {
// copy the incoming event so that we can delete properties as we go // copy the incoming event so that we can delete properties as we go
// everything left after we have deleted know properties becomes an extension // everything left after we have deleted know properties becomes an extension
const properties = { ...event }; const properties = { ...event };
@ -62,10 +62,10 @@ export class CloudEvent implements CloudEventV1 {
this.time = properties.time || new Date().toISOString(); this.time = properties.time || new Date().toISOString();
delete properties.time; delete properties.time;
this.type = properties.type; this.type = properties.type as string;
delete (properties as any).type; delete (properties as any).type;
this.source = properties.source; this.source = properties.source as string;
delete (properties as any).source; delete (properties as any).source;
this.specversion = (properties.specversion as Version) || Version.V1; 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`);
Object.freeze(this); Object.freeze(this);
} }
get data(): unknown { get data(): T | undefined {
return this.#_data; return this.#_data;
} }
set data(value: unknown) { set data(value: T | undefined) {
if (isBinary(value)) { if (isBinary(value)) {
this.data_base64 = asBase64(value as Uint32Array); this.data_base64 = asBase64(value);
} }
this.#_data = value; this.#_data = value;
} }
@ -184,16 +184,29 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`);
/** /**
* Clone a CloudEvent with new/update attributes * Clone a CloudEvent with new/update attributes
* @param {object} options attributes to augment the CloudEvent with * @param {object} options attributes to augment the CloudEvent with an `data` property
* @param {boolean} strict whether or not to use strict validation when cloning (default: true)
* @throws if the CloudEvent does not conform to the schema
* @return {CloudEvent} returns a new CloudEvent<T>
*/
public cloneWith(options: Partial<Exclude<CloudEventV1<never>, "data">>, strict?: boolean): CloudEvent<T>;
/**
* Clone a CloudEvent with new/update attributes
* @param {object} options attributes to augment the CloudEvent with a `data` property
* @param {boolean} strict whether or not to use strict validation when cloning (default: true)
* @throws if the CloudEvent does not conform to the schema
* @return {CloudEvent} returns a new CloudEvent<D>
*/
public cloneWith<D>(options: Partial<CloudEvent<D>>, strict?: boolean): CloudEvent<D>;
/**
* Clone a CloudEvent with new/update attributes
* @param {object} options attributes to augment the CloudEvent
* @param {boolean} strict whether or not to use strict validation when cloning (default: true) * @param {boolean} strict whether or not to use strict validation when cloning (default: true)
* @throws if the CloudEvent does not conform to the schema * @throws if the CloudEvent does not conform to the schema
* @return {CloudEvent} returns a new CloudEvent * @return {CloudEvent} returns a new CloudEvent
*/ */
public cloneWith( public cloneWith<D>(options: Partial<CloudEventV1<D>>, strict = true): CloudEvent<D | T> {
options: CloudEventV1 | CloudEventV1Attributes | CloudEventV1OptionalAttributes, return new CloudEvent(Object.assign({}, this.toJSON(), options), strict);
strict = true,
): CloudEvent {
return new CloudEvent(Object.assign({}, this.toJSON(), options) as CloudEvent, strict);
} }
/** /**

View File

@ -7,7 +7,7 @@
* The object interface for CloudEvents 1.0. * The object interface for CloudEvents 1.0.
* @see https://github.com/cloudevents/spec/blob/v1.0/spec.md * @see https://github.com/cloudevents/spec/blob/v1.0/spec.md
*/ */
export interface CloudEventV1 extends CloudEventV1Attributes { export interface CloudEventV1<T> extends CloudEventV1Attributes<T> {
// REQUIRED Attributes // REQUIRED Attributes
/** /**
* [REQUIRED] Identifies the event. Producers MUST ensure that `source` + `id` * [REQUIRED] Identifies the event. Producers MUST ensure that `source` + `id`
@ -30,7 +30,7 @@ export interface CloudEventV1 extends CloudEventV1Attributes {
specversion: string; specversion: string;
} }
export interface CloudEventV1Attributes extends CloudEventV1OptionalAttributes { export interface CloudEventV1Attributes<T> extends CloudEventV1OptionalAttributes<T> {
/** /**
* [REQUIRED] Identifies the context in which an event happened. Often this * [REQUIRED] Identifies the context in which an event happened. Often this
* will include information such as the type of the event source, the * will include information such as the type of the event source, the
@ -65,7 +65,7 @@ export interface CloudEventV1Attributes extends CloudEventV1OptionalAttributes {
type: string; type: string;
} }
export interface CloudEventV1OptionalAttributes { export interface CloudEventV1OptionalAttributes<T> {
/** /**
* The following fields are optional. * The following fields are optional.
*/ */
@ -126,7 +126,7 @@ export interface CloudEventV1OptionalAttributes {
* specified by the datacontenttype attribute (e.g. application/json), and adheres * specified by the datacontenttype attribute (e.g. application/json), and adheres
* to the dataschema format when those respective attributes are present. * to the dataschema format when those respective attributes are present.
*/ */
data?: Record<string, unknown | string | number | boolean> | string | number | boolean | null | unknown; data?: T;
/** /**
* [OPTIONAL] The event payload encoded as base64 data. This is used when the * [OPTIONAL] The event payload encoded as base64 data. This is used when the

View File

@ -21,7 +21,7 @@ ajv.addFormat("js-date-time", function (dateTimeString) {
const isValidAgainstSchemaV1 = ajv.compile(schemaV1); const isValidAgainstSchemaV1 = ajv.compile(schemaV1);
export function validateCloudEvent(event: CloudEventV1): boolean { export function validateCloudEvent<T>(event: CloudEventV1<T>): boolean {
if (event.specversion === Version.V1) { if (event.specversion === Version.V1) {
if (!isValidAgainstSchemaV1(event)) { if (!isValidAgainstSchemaV1(event)) {
throw new ValidationError("invalid payload", isValidAgainstSchemaV1.errors); throw new ValidationError("invalid payload", isValidAgainstSchemaV1.errors);

View File

@ -35,8 +35,8 @@ export const isDefined = (v: unknown): boolean => v !== null && typeof v !== "un
export const isBoolean = (v: unknown): boolean => typeof v === "boolean"; export const isBoolean = (v: unknown): boolean => typeof v === "boolean";
export const isInteger = (v: unknown): boolean => Number.isInteger(v as number); export const isInteger = (v: unknown): boolean => Number.isInteger(v as number);
export const isDate = (v: unknown): boolean => v instanceof Date; export const isDate = (v: unknown): v is Date => v instanceof Date;
export const isBinary = (v: unknown): boolean => v instanceof Uint32Array; export const isBinary = (v: unknown): v is Uint32Array => v instanceof Uint32Array;
export const isStringOrThrow = (v: unknown, t: Error): boolean => export const isStringOrThrow = (v: unknown, t: Error): boolean =>
isString(v) isString(v)
@ -75,7 +75,7 @@ export const isBuffer = (value: unknown): boolean => value instanceof Buffer;
export const asBuffer = (value: string | Buffer | Uint32Array): Buffer => export const asBuffer = (value: string | Buffer | Uint32Array): Buffer =>
isBinary(value) isBinary(value)
? Buffer.from(value as string) ? Buffer.from((value as unknown) as string)
: isBuffer(value) : isBuffer(value)
? (value as Buffer) ? (value as Buffer)
: (() => { : (() => {

View File

@ -24,7 +24,7 @@ export const requiredHeaders = [
* @param {CloudEvent} event a CloudEvent * @param {CloudEvent} event a CloudEvent
* @returns {Object} the headers that will be sent for the event * @returns {Object} the headers that will be sent for the event
*/ */
export function headersFor(event: CloudEvent): Headers { export function headersFor<T>(event: CloudEvent<T>): Headers {
const headers: Headers = {}; const headers: Headers = {};
let headerMap: Readonly<{ [key: string]: MappedParser }>; let headerMap: Readonly<{ [key: string]: MappedParser }>;
if (event.specversion === Version.V1) { if (event.specversion === Version.V1) {

View File

@ -25,13 +25,13 @@ import { JSONParser, MappedParser, Parser, parserByContentType } from "../../par
* @param {CloudEvent} event The event to serialize * @param {CloudEvent} event The event to serialize
* @returns {Message} a Message object with headers and body * @returns {Message} a Message object with headers and body
*/ */
export function binary(event: CloudEvent): Message { export function binary<T>(event: CloudEvent<T>): Message {
const contentType: Headers = { [CONSTANTS.HEADER_CONTENT_TYPE]: CONSTANTS.DEFAULT_CONTENT_TYPE }; const contentType: Headers = { [CONSTANTS.HEADER_CONTENT_TYPE]: CONSTANTS.DEFAULT_CONTENT_TYPE };
const headers: Headers = { ...contentType, ...headersFor(event) }; const headers: Headers = { ...contentType, ...headersFor(event) };
let body = event.data; let body = event.data;
if (typeof event.data === "object" && !(event.data instanceof Uint32Array)) { if (typeof event.data === "object" && !(event.data instanceof Uint32Array)) {
// we'll stringify objects, but not binary data // we'll stringify objects, but not binary data
body = JSON.stringify(event.data); body = (JSON.stringify(event.data) as unknown) as T;
} }
return { return {
headers, headers,
@ -47,7 +47,7 @@ export function binary(event: CloudEvent): Message {
* @param {CloudEvent} event the CloudEvent to be serialized * @param {CloudEvent} event the CloudEvent to be serialized
* @returns {Message} a Message object with headers and body * @returns {Message} a Message object with headers and body
*/ */
export function structured(event: CloudEvent): Message { export function structured<T>(event: CloudEvent<T>): Message {
if (event.data_base64) { if (event.data_base64) {
// The event's data is binary - delete it // The event's data is binary - delete it
event = event.cloneWith({ data: undefined }); event = event.cloneWith({ data: undefined });
@ -84,7 +84,7 @@ export function isEvent(message: Message): boolean {
* @param {Message} message the incoming message * @param {Message} message the incoming message
* @return {CloudEvent} A new {CloudEvent} instance * @return {CloudEvent} A new {CloudEvent} instance
*/ */
export function deserialize(message: Message): CloudEvent { export function deserialize<T>(message: Message): CloudEvent<T> {
const cleanHeaders: Headers = sanitize(message.headers); const cleanHeaders: Headers = sanitize(message.headers);
const mode: Mode = getMode(cleanHeaders); const mode: Mode = getMode(cleanHeaders);
const version = getVersion(mode, cleanHeaders, message.body); const version = getVersion(mode, cleanHeaders, message.body);
@ -133,7 +133,11 @@ function getVersion(mode: Mode, headers: Headers, body: string | Record<string,
} }
} else { } else {
// structured mode - the version is in the body // structured mode - the version is in the body
return typeof body === "string" ? JSON.parse(body).specversion : (body as CloudEvent).specversion; if (typeof body === "string") {
return JSON.parse(body).specversion;
} else {
return (body as Record<string, string>).specversion;
}
} }
return Version.V1; return Version.V1;
} }
@ -147,7 +151,7 @@ function getVersion(mode: Mode, headers: Headers, body: string | Record<string,
* @returns {CloudEvent} an instance of CloudEvent representing the incoming request * @returns {CloudEvent} an instance of CloudEvent representing the incoming request
* @throws {ValidationError} of the event does not conform to the spec * @throws {ValidationError} of the event does not conform to the spec
*/ */
function parseBinary(message: Message, version: Version): CloudEvent { function parseBinary<T>(message: Message, version: Version): CloudEvent<T> {
const headers = { ...message.headers }; const headers = { ...message.headers };
let body = message.body; let body = message.body;
@ -187,7 +191,7 @@ function parseBinary(message: Message, version: Version): CloudEvent {
delete eventObj.datacontentencoding; delete eventObj.datacontentencoding;
} }
return new CloudEvent({ ...eventObj, data: body } as CloudEventV1, false); return new CloudEvent<T>({ ...eventObj, data: body } as CloudEventV1<T>, false);
} }
/** /**
@ -198,7 +202,7 @@ function parseBinary(message: Message, version: Version): CloudEvent {
* @returns {CloudEvent} a new CloudEvent instance for the provided headers and payload * @returns {CloudEvent} a new CloudEvent instance for the provided headers and payload
* @throws {ValidationError} if the payload and header combination do not conform to the spec * @throws {ValidationError} if the payload and header combination do not conform to the spec
*/ */
function parseStructured(message: Message, version: Version): CloudEvent { function parseStructured<T>(message: Message, version: Version): CloudEvent<T> {
const payload = message.body; const payload = message.body;
const headers = message.headers; const headers = message.headers;
@ -240,5 +244,5 @@ function parseStructured(message: Message, version: Version): CloudEvent {
delete eventObj.data_base64; delete eventObj.data_base64;
delete eventObj.datacontentencoding; delete eventObj.datacontentencoding;
} }
return new CloudEvent(eventObj as CloudEventV1, false); return new CloudEvent<T>(eventObj as CloudEventV1<T>, false);
} }

View File

@ -61,7 +61,7 @@ export enum Mode {
* @interface * @interface
*/ */
export interface Serializer { export interface Serializer {
(event: CloudEvent): Message; <T>(event: CloudEvent<T>): Message;
} }
/** /**
@ -70,7 +70,7 @@ export interface Serializer {
* @interface * @interface
*/ */
export interface Deserializer { export interface Deserializer {
(message: Message): CloudEvent; <T>(message: Message): CloudEvent<T>;
} }
/** /**

View File

@ -23,7 +23,7 @@ export interface Options {
* @interface * @interface
*/ */
export interface EmitterFunction { export interface EmitterFunction {
(event: CloudEvent, options?: Options): Promise<unknown>; <T>(event: CloudEvent<T>, options?: Options): Promise<unknown>;
} }
/** /**
@ -56,7 +56,7 @@ export function emitterFor(fn: TransportFunction, options = emitterDefaults): Em
throw new TypeError("A TransportFunction is required"); throw new TypeError("A TransportFunction is required");
} }
const { binding, mode }: any = { ...emitterDefaults, ...options }; const { binding, mode }: any = { ...emitterDefaults, ...options };
return function emit(event: CloudEvent, opts?: Options): Promise<unknown> { return function emit<T>(event: CloudEvent<T>, opts?: Options): Promise<unknown> {
opts = opts || {}; opts = opts || {};
switch (mode) { switch (mode) {
@ -109,7 +109,7 @@ export class Emitter {
* @param {boolean} ensureDelivery fail the promise if one listener fails * @param {boolean} ensureDelivery fail the promise if one listener fails
* @return {void} * @return {void}
*/ */
static async emitEvent(event: CloudEvent, ensureDelivery = true): Promise<void> { static async emitEvent<T>(event: CloudEvent<T>, ensureDelivery = true): Promise<void> {
if (!ensureDelivery) { if (!ensureDelivery) {
// Ensure delivery is disabled so we don't wait for Promise // Ensure delivery is disabled so we don't wait for Promise
Emitter.getInstance().emit("cloudevent", event); Emitter.getInstance().emit("cloudevent", event);

View File

@ -8,14 +8,13 @@ import fs from "fs";
import { expect } from "chai"; import { expect } from "chai";
import { CloudEvent, ValidationError, Version } from "../../src"; import { CloudEvent, ValidationError, Version } from "../../src";
import { CloudEventV1 } from "../../src/event/interfaces";
import { asBase64 } from "../../src/event/validation"; import { asBase64 } from "../../src/event/validation";
const type = "org.cncf.cloudevents.example"; const type = "org.cncf.cloudevents.example";
const source = "http://unit.test"; const source = "http://unit.test";
const id = "b46cf653-d48a-4b90-8dfa-355c01061361"; const id = "b46cf653-d48a-4b90-8dfa-355c01061361";
const fixture: CloudEventV1 = { const fixture = {
id, id,
specversion: Version.V1, specversion: Version.V1,
source, source,
@ -34,17 +33,17 @@ describe("A CloudEvent", () => {
}); });
it("Can be constructed with loose validation", () => { it("Can be constructed with loose validation", () => {
const ce = new CloudEvent({} as CloudEventV1, false); const ce = new CloudEvent({}, false);
expect(ce).to.be.instanceOf(CloudEvent); expect(ce).to.be.instanceOf(CloudEvent);
}); });
it("Loosely validated events can be cloned", () => { it("Loosely validated events can be cloned", () => {
const ce = new CloudEvent({} as CloudEventV1, false); const ce = new CloudEvent({}, false);
expect(ce.cloneWith({}, false)).to.be.instanceOf(CloudEvent); expect(ce.cloneWith({}, false)).to.be.instanceOf(CloudEvent);
}); });
it("Loosely validated events throw when validated", () => { it("Loosely validated events throw when validated", () => {
const ce = new CloudEvent({} as CloudEventV1, false); const ce = new CloudEvent({}, false);
expect(ce.validate).to.throw(ValidationError, "invalid payload"); expect(ce.validate).to.throw(ValidationError, "invalid payload");
}); });

View File

@ -105,7 +105,7 @@ describe("HTTP transport", () => {
}, },
}; };
expect(HTTP.isEvent(message)).to.be.true; expect(HTTP.isEvent(message)).to.be.true;
const event: CloudEvent = HTTP.toEvent(message); const event = HTTP.toEvent(message);
expect(event.LUNCH).to.equal("tacos"); expect(event.LUNCH).to.equal("tacos");
expect(function () { expect(function () {
event.validate(); event.validate();
@ -124,7 +124,7 @@ describe("HTTP transport", () => {
}, },
}; };
expect(HTTP.isEvent(message)).to.be.true; expect(HTTP.isEvent(message)).to.be.true;
const event: CloudEvent = HTTP.toEvent(message); const event = HTTP.toEvent(message);
expect(event.specversion).to.equal("11.8"); expect(event.specversion).to.equal("11.8");
expect(event.validate()).to.be.false; expect(event.validate()).to.be.false;
}); });
@ -195,7 +195,7 @@ describe("HTTP transport", () => {
}); });
describe("Specification version V1", () => { describe("Specification version V1", () => {
const fixture: CloudEvent = new CloudEvent({ const fixture = new CloudEvent({
specversion: Version.V1, specversion: Version.V1,
id, id,
type, type,
@ -298,7 +298,7 @@ describe("HTTP transport", () => {
}); });
describe("Specification version V03", () => { describe("Specification version V03", () => {
const fixture: CloudEvent = new CloudEvent({ const fixture = new CloudEvent({
specversion: Version.V03, specversion: Version.V03,
id, id,
type, type,

View File

@ -164,13 +164,13 @@ describe("CloudEvents Spec v1.0", () => {
expect(cloudevent.data).to.deep.equal(data); expect(cloudevent.data).to.deep.equal(data);
}); });
it("should maintain the type of data when no data content type", () => { it("should maintain the type of data when no datacontenttype is provided", () => {
const dct = cloudevent.datacontenttype; const ce = new CloudEvent({
cloudevent = cloudevent.cloneWith({ datacontenttype: undefined }); source: "/cloudevents/test",
cloudevent.data = JSON.stringify(data); type: "cloudevents.test",
data: JSON.stringify(data),
expect(typeof cloudevent.data).to.equal("string"); });
cloudevent = cloudevent.cloneWith({ datacontenttype: dct }); expect(typeof ce.data).to.equal("string");
}); });
it("should be ok when type is 'Uint32Array' for 'Binary'", () => { it("should be ok when type is 'Uint32Array' for 'Binary'", () => {
@ -179,9 +179,8 @@ describe("CloudEvents Spec v1.0", () => {
const dataBinary = Uint32Array.from(dataString, (c) => c.codePointAt(0) as number); const dataBinary = Uint32Array.from(dataString, (c) => c.codePointAt(0) as number);
const expected = asBase64(dataBinary); const expected = asBase64(dataBinary);
cloudevent = cloudevent.cloneWith({ datacontenttype: "text/plain", data: dataBinary }); const ce = cloudevent.cloneWith({ datacontenttype: "text/plain", data: dataBinary });
expect(ce.data_base64).to.equal(expected);
expect(cloudevent.data_base64).to.equal(expected);
}); });
}); });
}); });