Skip to content

feat: add a constructor parameter for loose validation #328

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 2 commits into from
Sep 8, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/event/cloudevent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
*/
Expand All @@ -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);
}
}
13 changes: 12 additions & 1 deletion src/event/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) =>
(accum as string).concat(`
${err instanceof Object ? JSON.stringify(err) : err}`),
message,
)
: message;
super(messageString);
this.errors = errors ? errors : [];
}
}
Expand Down
36 changes: 6 additions & 30 deletions src/message/http/headers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
15 changes: 4 additions & 11 deletions src/message/http/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, unknown> } = {};
const parserMap: Record<string, MappedParser> = version === Version.V1 ? v1binaryParsers : v1binaryParsers;
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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);
}
24 changes: 20 additions & 4 deletions test/integration/cloud_event_test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,6 +11,7 @@ const fixture: CloudEventV1 = {
specversion: Version.V1,
source,
type,
data: `"some data"`,
};

describe("A CloudEvent", () => {
Expand All @@ -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));
Expand Down Expand Up @@ -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");
}
});

Expand Down Expand Up @@ -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");
}
});

Expand Down
45 changes: 41 additions & 4 deletions test/integration/message_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down