From d941e2d4d9e491912860e30acd7fa34e9dda7669 Mon Sep 17 00:00:00 2001 From: Lance Ball Date: Mon, 29 Nov 2021 17:03:12 -0500 Subject: [PATCH] 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 --- src/event/cloudevent.ts | 43 ++++++++++++++++++---------- src/event/interfaces.ts | 8 +++--- src/event/spec.ts | 2 +- src/event/validation.ts | 6 ++-- src/message/http/headers.ts | 2 +- src/message/http/index.ts | 22 ++++++++------ src/message/index.ts | 4 +-- src/transport/emitter.ts | 6 ++-- test/integration/cloud_event_test.ts | 9 +++--- test/integration/message_test.ts | 8 +++--- test/integration/spec_1_tests.ts | 19 ++++++------ 11 files changed, 72 insertions(+), 57 deletions(-) diff --git a/src/event/cloudevent.ts b/src/event/cloudevent.ts index 7febb91..75d5521 100644 --- a/src/event/cloudevent.ts +++ b/src/event/cloudevent.ts @@ -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 implements CloudEventV1 { id: string; type: string; source: string; @@ -32,7 +32,7 @@ export class CloudEvent implements CloudEventV1 { dataschema?: string; subject?: string; time?: string; - #_data?: Record | 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>, 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 + */ + public cloneWith(options: Partial, "data">>, strict?: boolean): CloudEvent; + /** + * 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 + */ + public cloneWith(options: Partial>, strict?: boolean): CloudEvent; + /** + * 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(options: Partial>, strict = true): CloudEvent { + return new CloudEvent(Object.assign({}, this.toJSON(), options), strict); } /** diff --git a/src/event/interfaces.ts b/src/event/interfaces.ts index 039d724..f73c6d8 100644 --- a/src/event/interfaces.ts +++ b/src/event/interfaces.ts @@ -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 extends CloudEventV1Attributes { // 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 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 @@ -65,7 +65,7 @@ export interface CloudEventV1Attributes extends CloudEventV1OptionalAttributes { type: string; } -export interface CloudEventV1OptionalAttributes { +export interface CloudEventV1OptionalAttributes { /** * 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 | number | boolean | null | unknown; + data?: T; /** * [OPTIONAL] The event payload encoded as base64 data. This is used when the diff --git a/src/event/spec.ts b/src/event/spec.ts index cf1ed44..e1f514f 100644 --- a/src/event/spec.ts +++ b/src/event/spec.ts @@ -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(event: CloudEventV1): boolean { if (event.specversion === Version.V1) { if (!isValidAgainstSchemaV1(event)) { throw new ValidationError("invalid payload", isValidAgainstSchemaV1.errors); diff --git a/src/event/validation.ts b/src/event/validation.ts index 5b072bb..0b6fbed 100644 --- a/src/event/validation.ts +++ b/src/event/validation.ts @@ -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) : (() => { diff --git a/src/message/http/headers.ts b/src/message/http/headers.ts index 91b3830..510488f 100644 --- a/src/message/http/headers.ts +++ b/src/message/http/headers.ts @@ -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(event: CloudEvent): Headers { const headers: Headers = {}; let headerMap: Readonly<{ [key: string]: MappedParser }>; if (event.specversion === Version.V1) { diff --git a/src/message/http/index.ts b/src/message/http/index.ts index 05e8037..d45c62c 100644 --- a/src/message/http/index.ts +++ b/src/message/http/index.ts @@ -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(event: CloudEvent): 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(event: CloudEvent): 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(message: Message): CloudEvent { 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).specversion; + } } return Version.V1; } @@ -147,7 +151,7 @@ function getVersion(mode: Mode, headers: Headers, body: string | Record(message: Message, version: Version): CloudEvent { 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({ ...eventObj, data: body } as CloudEventV1, 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(message: Message, version: Version): CloudEvent { 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(eventObj as CloudEventV1, false); } diff --git a/src/message/index.ts b/src/message/index.ts index 6365d29..3fac1d1 100644 --- a/src/message/index.ts +++ b/src/message/index.ts @@ -61,7 +61,7 @@ export enum Mode { * @interface */ export interface Serializer { - (event: CloudEvent): Message; + (event: CloudEvent): Message; } /** @@ -70,7 +70,7 @@ export interface Serializer { * @interface */ export interface Deserializer { - (message: Message): CloudEvent; + (message: Message): CloudEvent; } /** diff --git a/src/transport/emitter.ts b/src/transport/emitter.ts index 7fd6887..2a8b604 100644 --- a/src/transport/emitter.ts +++ b/src/transport/emitter.ts @@ -23,7 +23,7 @@ export interface Options { * @interface */ export interface EmitterFunction { - (event: CloudEvent, options?: Options): Promise; + (event: CloudEvent, options?: Options): Promise; } /** @@ -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 { + return function emit(event: CloudEvent, opts?: Options): Promise { 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 { + static async emitEvent(event: CloudEvent, ensureDelivery = true): Promise { if (!ensureDelivery) { // Ensure delivery is disabled so we don't wait for Promise Emitter.getInstance().emit("cloudevent", event); diff --git a/test/integration/cloud_event_test.ts b/test/integration/cloud_event_test.ts index 1f9a4b2..0bc8c8a 100644 --- a/test/integration/cloud_event_test.ts +++ b/test/integration/cloud_event_test.ts @@ -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"); }); diff --git a/test/integration/message_test.ts b/test/integration/message_test.ts index 0a58c73..74a5811 100644 --- a/test/integration/message_test.ts +++ b/test/integration/message_test.ts @@ -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, diff --git a/test/integration/spec_1_tests.ts b/test/integration/spec_1_tests.ts index 90c3fa7..e83decd 100644 --- a/test/integration/spec_1_tests.ts +++ b/test/integration/spec_1_tests.ts @@ -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); }); }); });