diff --git a/src/event/cloudevent.ts b/src/event/cloudevent.ts index 197b4bc1..df47b297 100644 --- a/src/event/cloudevent.ts +++ b/src/event/cloudevent.ts @@ -46,7 +46,15 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 { schemaurl?: string; datacontentencoding?: string; - constructor(event: CloudEventV1 | CloudEventV1Attributes | CloudEventV03 | CloudEventV03Attributes) { + /** + * Creates a new CloudEvent object with the provided properties. If there is a chance that the event + * properties will not conform to the CloudEvent specification, you may pass a boolean `false` as a + * second parameter to bypass event validation. + * + * @param {object} event the event properties + * @param {boolean?} strict whether to perform event validation when creating the object - default: true + */ + constructor(event: CloudEventV1 | CloudEventV1Attributes | CloudEventV03 | CloudEventV03Attributes, 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 }; @@ -105,20 +113,20 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 { for (const [key, value] of Object.entries(properties)) { // Extension names should only allow lowercase a-z and 0-9 in the name // names should not exceed 20 characters in length - if (!key.match(/^[a-z0-9]{1,20}$/)) { + if (!key.match(/^[a-z0-9]{1,20}$/) && strict) { throw new ValidationError("invalid extension name"); } // Value should be spec compliant // https://github.com/cloudevents/spec/blob/master/spec.md#type-system - if (!isValidType(value)) { + if (!isValidType(value) && strict) { throw new ValidationError("invalid extension value"); } this[key] = value; } - this.validate(); + strict ? this.validate() : undefined; Object.freeze(this); } @@ -193,6 +201,7 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 { /** * Clone a CloudEvent with new/update attributes * @param {object} options attributes to augment the CloudEvent with + * @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 */ @@ -204,7 +213,8 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 { | CloudEventV03 | CloudEventV03Attributes | CloudEventV03OptionalAttributes, + strict = true, ): CloudEvent { - return new CloudEvent(Object.assign({}, this.toJSON(), options) as CloudEvent); + return new CloudEvent(Object.assign({}, this.toJSON(), options) as CloudEvent, strict); } } diff --git a/src/event/validation.ts b/src/event/validation.ts index 6ca0258d..645df308 100644 --- a/src/event/validation.ts +++ b/src/event/validation.ts @@ -8,7 +8,18 @@ export class ValidationError extends TypeError { errors?: string[] | ErrorObject[] | null; constructor(message: string, errors?: string[] | ErrorObject[] | null) { - super(message); + const messageString = + errors instanceof Array + ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + errors?.reduce( + (accum: string, err: Record) => + (accum as string).concat(` + ${err instanceof Object ? JSON.stringify(err) : err}`), + message, + ) + : message; + super(messageString); this.errors = errors ? errors : []; } } diff --git a/src/message/http/headers.ts b/src/message/http/headers.ts index 228445cb..dcef105c 100644 --- a/src/message/http/headers.ts +++ b/src/message/http/headers.ts @@ -1,5 +1,5 @@ import { PassThroughParser, DateParser, MappedParser } from "../../parsers"; -import { ValidationError, CloudEvent } from "../.."; +import { CloudEvent } from "../.."; import { Headers } from "../"; import { Version } from "../../event/cloudevent"; import CONSTANTS from "../../constants"; @@ -12,35 +12,6 @@ export const requiredHeaders = [ CONSTANTS.CE_HEADERS.SPEC_VERSION, ]; -/** - * Validates cloud event headers and their values - * @param {Headers} headers event transport headers for validation - * @throws {ValidationError} if the headers are invalid - * @return {boolean} true if headers are valid - */ -export function validate(headers: Headers): Headers { - const sanitizedHeaders = sanitize(headers); - - // if content-type exists, be sure it's an allowed type - const contentTypeHeader = sanitizedHeaders[CONSTANTS.HEADER_CONTENT_TYPE]; - const noContentType = !allowedContentTypes.includes(contentTypeHeader); - if (contentTypeHeader && noContentType) { - throw new ValidationError("invalid content type", [sanitizedHeaders[CONSTANTS.HEADER_CONTENT_TYPE]]); - } - - requiredHeaders - .filter((required: string) => !sanitizedHeaders[required]) - .forEach((required: string) => { - throw new ValidationError(`header '${required}' not found`); - }); - - if (!sanitizedHeaders[CONSTANTS.HEADER_CONTENT_TYPE]) { - sanitizedHeaders[CONSTANTS.HEADER_CONTENT_TYPE] = CONSTANTS.MIME_JSON; - } - - return sanitizedHeaders; -} - /** * Returns the HTTP headers that will be sent for this event when the HTTP transmission * mode is "binary". Events sent over HTTP in structured mode only have a single CE header @@ -89,6 +60,11 @@ export function sanitize(headers: Headers): Headers { .filter((header) => Object.hasOwnProperty.call(headers, header)) .forEach((header) => (sanitized[header.toLowerCase()] = headers[header])); + // If no content-type header is sent, assume application/json + if (!sanitized[CONSTANTS.HEADER_CONTENT_TYPE]) { + sanitized[CONSTANTS.HEADER_CONTENT_TYPE] = CONSTANTS.MIME_JSON; + } + return sanitized; } diff --git a/src/message/http/index.ts b/src/message/http/index.ts index c93917de..492f4916 100644 --- a/src/message/http/index.ts +++ b/src/message/http/index.ts @@ -1,9 +1,8 @@ import { CloudEvent, CloudEventV03, CloudEventV1, CONSTANTS, Mode, Version } from "../.."; import { Message, Headers } from ".."; -import { headersFor, sanitize, v03structuredParsers, v1binaryParsers, v1structuredParsers, validate } from "./headers"; +import { headersFor, sanitize, v03structuredParsers, v1binaryParsers, v1structuredParsers } from "./headers"; import { asData, isBase64, isString, isStringOrObjectOrThrow, ValidationError } from "../../event/validation"; -import { validateCloudEvent } from "../../event/spec"; import { Base64Parser, JSONParser, MappedParser, Parser, parserByContentType } from "../../parsers"; // implements Serializer @@ -129,7 +128,7 @@ function parseBinary(message: Message, version: Version): CloudEvent { body = isString(body) && isBase64(body) ? Buffer.from(body as string, "base64").toString() : body; // Clone and low case all headers names - const sanitizedHeaders = validate(headers); + const sanitizedHeaders = sanitize(headers); const eventObj: { [key: string]: unknown | string | Record } = {}; const parserMap: Record = version === Version.V1 ? v1binaryParsers : v1binaryParsers; @@ -165,9 +164,7 @@ function parseBinary(message: Message, version: Version): CloudEvent { delete eventObj.datacontentencoding; } - const cloudevent = new CloudEvent({ ...eventObj, data: parsedPayload } as CloudEventV1 | CloudEventV03); - validateCloudEvent(cloudevent); - return cloudevent; + return new CloudEvent({ ...eventObj, data: parsedPayload } as CloudEventV1 | CloudEventV03, false); } /** @@ -226,9 +223,5 @@ function parseStructured(message: Message, version: Version): CloudEvent { delete eventObj.data_base64; delete eventObj.datacontentencoding; } - const cloudevent = new CloudEvent(eventObj as CloudEventV1 | CloudEventV03); - - // Validates the event - validateCloudEvent(cloudevent); - return cloudevent; + return new CloudEvent(eventObj as CloudEventV1 | CloudEventV03, false); } diff --git a/test/integration/cloud_event_test.ts b/test/integration/cloud_event_test.ts index 70881c96..5b4ba134 100644 --- a/test/integration/cloud_event_test.ts +++ b/test/integration/cloud_event_test.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { CloudEvent, Version } from "../../src"; +import { CloudEvent, ValidationError, Version } from "../../src"; import { CloudEventV03, CloudEventV1 } from "../../src/event/interfaces"; const type = "org.cncf.cloudevents.example"; @@ -11,6 +11,7 @@ const fixture: CloudEventV1 = { specversion: Version.V1, source, type, + data: `"some data"`, }; describe("A CloudEvent", () => { @@ -20,6 +21,21 @@ describe("A CloudEvent", () => { expect(ce.source).to.equal(source); }); + it("Can be constructed with loose validation", () => { + const ce = new CloudEvent({} as CloudEventV1, false); + expect(ce).to.be.instanceOf(CloudEvent); + }); + + it("Loosely validated events can be cloned", () => { + const ce = new CloudEvent({} as CloudEventV1, false); + expect(ce.cloneWith({}, false)).to.be.instanceOf(CloudEvent); + }); + + it("Loosely validated events throw when validated", () => { + const ce = new CloudEvent({} as CloudEventV1, false); + expect(ce.validate).to.throw(ValidationError, "invalid payload"); + }); + it("serializes as JSON with toString()", () => { const ce = new CloudEvent(fixture); expect(ce.toString()).to.deep.equal(JSON.stringify(ce)); @@ -152,7 +168,7 @@ describe("A 1.0 CloudEvent", () => { }); } catch (err) { expect(err).to.be.instanceOf(TypeError); - expect(err.message).to.equal("invalid payload"); + expect(err.message).to.include("invalid payload"); } }); @@ -235,8 +251,8 @@ describe("A 0.3 CloudEvent", () => { source: (null as unknown) as string, }); } catch (err) { - expect(err).to.be.instanceOf(TypeError); - expect(err.message).to.equal("invalid payload"); + expect(err).to.be.instanceOf(ValidationError); + expect(err.message).to.include("invalid payload"); } }); diff --git a/test/integration/message_test.ts b/test/integration/message_test.ts index c6a3315f..55f0c04c 100644 --- a/test/integration/message_test.ts +++ b/test/integration/message_test.ts @@ -27,19 +27,21 @@ const ext2Value = "acme"; const dataBinary = Uint32Array.from(JSON.stringify(data), (c) => c.codePointAt(0) as number); const data_base64 = asBase64(dataBinary); -describe("HTTP transport messages", () => { - it("can detect CloudEvent Messages", () => { +describe("HTTP transport", () => { + it("Can detect invalid CloudEvent Messages", () => { // Create a message that is not an actual event - let message: Message = { + const message: Message = { body: "Hello world!", headers: { "Content-type": "text/plain", }, }; expect(HTTP.isEvent(message)).to.be.false; + }); + it("Can detect valid CloudEvent Messages", () => { // Now create a message that is an event - message = HTTP.binary( + const message = HTTP.binary( new CloudEvent({ source: "/message-test", type: "example", @@ -48,6 +50,41 @@ describe("HTTP transport messages", () => { expect(HTTP.isEvent(message)).to.be.true; }); + // Allow for external systems to send bad events - do what we can + // to accept them + it("Does not throw an exception when converting an invalid Message to a CloudEvent", () => { + const message: Message = { + body: `"hello world"`, + headers: { + "content-type": "application/json", + "ce-id": "1234", + "ce-type": "example.bad.event", + "ce-specversion": "1.0", + // no required ce-source header, thus an invalid event + }, + }; + const event = HTTP.toEvent(message); + expect(event).to.be.instanceOf(CloudEvent); + // ensure that we actually now have an invalid event + expect(event.validate).to.throw; + }); + + it("Does not allow an invalid CloudEvent to be converted to a Message", () => { + const badEvent = new CloudEvent( + { + source: "/example.source", + type: "", // type is required, empty string will throw with strict validation + }, + false, // turn off strict validation + ); + expect(() => { + HTTP.binary(badEvent); + }).to.throw; + expect(() => { + HTTP.structured(badEvent); + }).to.throw; + }); + describe("Specification version V1", () => { const fixture: CloudEvent = new CloudEvent({ specversion: Version.V1,