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 { Emitter } from "..";
import { CloudEventV1, CloudEventV1Attributes, CloudEventV1OptionalAttributes } from "./interfaces";
import { CloudEventV1 } from "./interfaces";
import { validateCloudEvent } from "./spec";
import { ValidationError, isBinary, asBase64, isValidType } from "./validation";
@ -23,7 +23,7 @@ export const enum Version {
* interoperability across services, platforms and systems.
* @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;
type: string;
source: string;
@ -32,7 +32,7 @@ export class CloudEvent implements CloudEventV1 {
dataschema?: string;
subject?: string;
time?: string;
#_data?: Record<string, unknown | string | number | boolean> | string | number | boolean | null | unknown;
#_data?: T;
data_base64?: string;
// 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 {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
// everything left after we have deleted know properties becomes an extension
const properties = { ...event };
@ -62,10 +62,10 @@ export class CloudEvent implements CloudEventV1 {
this.time = properties.time || new Date().toISOString();
delete properties.time;
this.type = properties.type;
this.type = properties.type as string;
delete (properties as any).type;
this.source = properties.source;
this.source = properties.source as string;
delete (properties as any).source;
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);
}
get data(): unknown {
get data(): T | undefined {
return this.#_data;
}
set data(value: unknown) {
set data(value: T | undefined) {
if (isBinary(value)) {
this.data_base64 = asBase64(value as Uint32Array);
this.data_base64 = asBase64(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
* @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)
* @throws if the CloudEvent does not conform to the schema
* @return {CloudEvent} returns a new CloudEvent
*/
public cloneWith(
options: CloudEventV1 | CloudEventV1Attributes | CloudEventV1OptionalAttributes,
strict = true,
): CloudEvent {
return new CloudEvent(Object.assign({}, this.toJSON(), options) as CloudEvent, strict);
public cloneWith<D>(options: Partial<CloudEventV1<D>>, strict = true): CloudEvent<D | T> {
return new CloudEvent(Object.assign({}, this.toJSON(), options), strict);
}
/**

View File

@ -7,7 +7,7 @@
* The object interface for CloudEvents 1.0.
* @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] Identifies the event. Producers MUST ensure that `source` + `id`
@ -30,7 +30,7 @@ export interface CloudEventV1 extends CloudEventV1Attributes {
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
* will include information such as the type of the event source, the
@ -65,7 +65,7 @@ export interface CloudEventV1Attributes extends CloudEventV1OptionalAttributes {
type: string;
}
export interface CloudEventV1OptionalAttributes {
export interface CloudEventV1OptionalAttributes<T> {
/**
* The following fields are optional.
*/
@ -126,7 +126,7 @@ export interface CloudEventV1OptionalAttributes {
* specified by the datacontenttype attribute (e.g. application/json), and adheres
* 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

View File

@ -21,7 +21,7 @@ ajv.addFormat("js-date-time", function (dateTimeString) {
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 (!isValidAgainstSchemaV1(event)) {
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 isInteger = (v: unknown): boolean => Number.isInteger(v as number);
export const isDate = (v: unknown): boolean => v instanceof Date;
export const isBinary = (v: unknown): boolean => v instanceof Uint32Array;
export const isDate = (v: unknown): v is Date => v instanceof Date;
export const isBinary = (v: unknown): v is Uint32Array => v instanceof Uint32Array;
export const isStringOrThrow = (v: unknown, t: Error): boolean =>
isString(v)
@ -75,7 +75,7 @@ export const isBuffer = (value: unknown): boolean => value instanceof Buffer;
export const asBuffer = (value: string | Buffer | Uint32Array): Buffer =>
isBinary(value)
? Buffer.from(value as string)
? Buffer.from((value as unknown) as string)
: isBuffer(value)
? (value as Buffer)
: (() => {

View File

@ -24,7 +24,7 @@ export const requiredHeaders = [
* @param {CloudEvent} event a CloudEvent
* @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 = {};
let headerMap: Readonly<{ [key: string]: MappedParser }>;
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
* @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 headers: Headers = { ...contentType, ...headersFor(event) };
let body = event.data;
if (typeof event.data === "object" && !(event.data instanceof Uint32Array)) {
// we'll stringify objects, but not binary data
body = JSON.stringify(event.data);
body = (JSON.stringify(event.data) as unknown) as T;
}
return {
headers,
@ -47,7 +47,7 @@ export function binary(event: CloudEvent): Message {
* @param {CloudEvent} event the CloudEvent to be serialized
* @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) {
// The event's data is binary - delete it
event = event.cloneWith({ data: undefined });
@ -84,7 +84,7 @@ export function isEvent(message: Message): boolean {
* @param {Message} message the incoming message
* @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 mode: Mode = getMode(cleanHeaders);
const version = getVersion(mode, cleanHeaders, message.body);
@ -133,7 +133,11 @@ function getVersion(mode: Mode, headers: Headers, body: string | Record<string,
}
} else {
// 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;
}
@ -147,7 +151,7 @@ function getVersion(mode: Mode, headers: Headers, body: string | Record<string,
* @returns {CloudEvent} an instance of CloudEvent representing the incoming request
* @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 };
let body = message.body;
@ -187,7 +191,7 @@ function parseBinary(message: Message, version: Version): CloudEvent {
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
* @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 headers = message.headers;
@ -240,5 +244,5 @@ function parseStructured(message: Message, version: Version): CloudEvent {
delete eventObj.data_base64;
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
*/
export interface Serializer {
(event: CloudEvent): Message;
<T>(event: CloudEvent<T>): Message;
}
/**
@ -70,7 +70,7 @@ export interface Serializer {
* @interface
*/
export interface Deserializer {
(message: Message): CloudEvent;
<T>(message: Message): CloudEvent<T>;
}
/**

View File

@ -23,7 +23,7 @@ export interface Options {
* @interface
*/
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");
}
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 || {};
switch (mode) {
@ -109,7 +109,7 @@ export class Emitter {
* @param {boolean} ensureDelivery fail the promise if one listener fails
* @return {void}
*/
static async emitEvent(event: CloudEvent, ensureDelivery = true): Promise<void> {
static async emitEvent<T>(event: CloudEvent<T>, ensureDelivery = true): Promise<void> {
if (!ensureDelivery) {
// Ensure delivery is disabled so we don't wait for Promise
Emitter.getInstance().emit("cloudevent", event);

View File

@ -8,14 +8,13 @@ import fs from "fs";
import { expect } from "chai";
import { CloudEvent, ValidationError, Version } from "../../src";
import { CloudEventV1 } from "../../src/event/interfaces";
import { asBase64 } from "../../src/event/validation";
const type = "org.cncf.cloudevents.example";
const source = "http://unit.test";
const id = "b46cf653-d48a-4b90-8dfa-355c01061361";
const fixture: CloudEventV1 = {
const fixture = {
id,
specversion: Version.V1,
source,
@ -34,17 +33,17 @@ describe("A CloudEvent", () => {
});
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);
});
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);
});
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");
});

View File

@ -105,7 +105,7 @@ describe("HTTP transport", () => {
},
};
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(function () {
event.validate();
@ -124,7 +124,7 @@ describe("HTTP transport", () => {
},
};
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.validate()).to.be.false;
});
@ -195,7 +195,7 @@ describe("HTTP transport", () => {
});
describe("Specification version V1", () => {
const fixture: CloudEvent = new CloudEvent({
const fixture = new CloudEvent({
specversion: Version.V1,
id,
type,
@ -298,7 +298,7 @@ describe("HTTP transport", () => {
});
describe("Specification version V03", () => {
const fixture: CloudEvent = new CloudEvent({
const fixture = new CloudEvent({
specversion: Version.V03,
id,
type,

View File

@ -164,13 +164,13 @@ describe("CloudEvents Spec v1.0", () => {
expect(cloudevent.data).to.deep.equal(data);
});
it("should maintain the type of data when no data content type", () => {
const dct = cloudevent.datacontenttype;
cloudevent = cloudevent.cloneWith({ datacontenttype: undefined });
cloudevent.data = JSON.stringify(data);
expect(typeof cloudevent.data).to.equal("string");
cloudevent = cloudevent.cloneWith({ datacontenttype: dct });
it("should maintain the type of data when no datacontenttype is provided", () => {
const ce = new CloudEvent({
source: "/cloudevents/test",
type: "cloudevents.test",
data: JSON.stringify(data),
});
expect(typeof ce.data).to.equal("string");
});
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 expected = asBase64(dataBinary);
cloudevent = cloudevent.cloneWith({ datacontenttype: "text/plain", data: dataBinary });
expect(cloudevent.data_base64).to.equal(expected);
const ce = cloudevent.cloneWith({ datacontenttype: "text/plain", data: dataBinary });
expect(ce.data_base64).to.equal(expected);
});
});
});