feat: add a constructor parameter for loose validation (#328)
* feat: add a constructor parameter for loose validation This commit adds a second, optional boolean parameter to the `CloudEvent` constructor. When `false` is provided, the event constructor will not perform validation of the event properties, values and extension names. This commit also modifies the ValidationError class so that the error message string includes the JSON.stringified version of any schema validation errors. It also makes the HTTP.toEvent() function create CloudEvent objects with loose/no validation. Incorporates comments from https://github.com/cloudevents/sdk-javascript/pull/328 Fixes: https://github.com/cloudevents/sdk-javascript/issues/325 Signed-off-by: Lance Ball <lball@redhat.com>
This commit is contained in:
parent
08e98c7fe9
commit
ae21dba76e
|
@ -46,7 +46,15 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 {
|
||||||
schemaurl?: string;
|
schemaurl?: string;
|
||||||
datacontentencoding?: 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
|
// 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 };
|
||||||
|
@ -105,20 +113,20 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 {
|
||||||
for (const [key, value] of Object.entries(properties)) {
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
// Extension names should only allow lowercase a-z and 0-9 in the name
|
// Extension names should only allow lowercase a-z and 0-9 in the name
|
||||||
// names should not exceed 20 characters in length
|
// 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");
|
throw new ValidationError("invalid extension name");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value should be spec compliant
|
// Value should be spec compliant
|
||||||
// https://github.com/cloudevents/spec/blob/master/spec.md#type-system
|
// https://github.com/cloudevents/spec/blob/master/spec.md#type-system
|
||||||
if (!isValidType(value)) {
|
if (!isValidType(value) && strict) {
|
||||||
throw new ValidationError("invalid extension value");
|
throw new ValidationError("invalid extension value");
|
||||||
}
|
}
|
||||||
|
|
||||||
this[key] = value;
|
this[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.validate();
|
strict ? this.validate() : undefined;
|
||||||
|
|
||||||
Object.freeze(this);
|
Object.freeze(this);
|
||||||
}
|
}
|
||||||
|
@ -193,6 +201,7 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 {
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
* @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
|
||||||
*/
|
*/
|
||||||
|
@ -204,7 +213,8 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 {
|
||||||
| CloudEventV03
|
| CloudEventV03
|
||||||
| CloudEventV03Attributes
|
| CloudEventV03Attributes
|
||||||
| CloudEventV03OptionalAttributes,
|
| CloudEventV03OptionalAttributes,
|
||||||
|
strict = true,
|
||||||
): CloudEvent {
|
): CloudEvent {
|
||||||
return new CloudEvent(Object.assign({}, this.toJSON(), options) as CloudEvent);
|
return new CloudEvent(Object.assign({}, this.toJSON(), options) as CloudEvent, strict);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,18 @@ export class ValidationError extends TypeError {
|
||||||
errors?: string[] | ErrorObject[] | null;
|
errors?: string[] | ErrorObject[] | null;
|
||||||
|
|
||||||
constructor(message: string, 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 : [];
|
this.errors = errors ? errors : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { PassThroughParser, DateParser, MappedParser } from "../../parsers";
|
import { PassThroughParser, DateParser, MappedParser } from "../../parsers";
|
||||||
import { ValidationError, CloudEvent } from "../..";
|
import { CloudEvent } from "../..";
|
||||||
import { Headers } from "../";
|
import { Headers } from "../";
|
||||||
import { Version } from "../../event/cloudevent";
|
import { Version } from "../../event/cloudevent";
|
||||||
import CONSTANTS from "../../constants";
|
import CONSTANTS from "../../constants";
|
||||||
|
@ -12,35 +12,6 @@ export const requiredHeaders = [
|
||||||
CONSTANTS.CE_HEADERS.SPEC_VERSION,
|
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
|
* 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
|
* 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))
|
.filter((header) => Object.hasOwnProperty.call(headers, header))
|
||||||
.forEach((header) => (sanitized[header.toLowerCase()] = 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;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { CloudEvent, CloudEventV03, CloudEventV1, CONSTANTS, Mode, Version } from "../..";
|
import { CloudEvent, CloudEventV03, CloudEventV1, CONSTANTS, Mode, Version } from "../..";
|
||||||
import { Message, Headers } 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 { asData, isBase64, isString, isStringOrObjectOrThrow, ValidationError } from "../../event/validation";
|
||||||
import { validateCloudEvent } from "../../event/spec";
|
|
||||||
import { Base64Parser, JSONParser, MappedParser, Parser, parserByContentType } from "../../parsers";
|
import { Base64Parser, JSONParser, MappedParser, Parser, parserByContentType } from "../../parsers";
|
||||||
|
|
||||||
// implements Serializer
|
// 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;
|
body = isString(body) && isBase64(body) ? Buffer.from(body as string, "base64").toString() : body;
|
||||||
|
|
||||||
// Clone and low case all headers names
|
// 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 eventObj: { [key: string]: unknown | string | Record<string, unknown> } = {};
|
||||||
const parserMap: Record<string, MappedParser> = version === Version.V1 ? v1binaryParsers : v1binaryParsers;
|
const parserMap: Record<string, MappedParser> = version === Version.V1 ? v1binaryParsers : v1binaryParsers;
|
||||||
|
@ -165,9 +164,7 @@ function parseBinary(message: Message, version: Version): CloudEvent {
|
||||||
delete eventObj.datacontentencoding;
|
delete eventObj.datacontentencoding;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cloudevent = new CloudEvent({ ...eventObj, data: parsedPayload } as CloudEventV1 | CloudEventV03);
|
return new CloudEvent({ ...eventObj, data: parsedPayload } as CloudEventV1 | CloudEventV03, false);
|
||||||
validateCloudEvent(cloudevent);
|
|
||||||
return cloudevent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -226,9 +223,5 @@ function parseStructured(message: Message, version: Version): CloudEvent {
|
||||||
delete eventObj.data_base64;
|
delete eventObj.data_base64;
|
||||||
delete eventObj.datacontentencoding;
|
delete eventObj.datacontentencoding;
|
||||||
}
|
}
|
||||||
const cloudevent = new CloudEvent(eventObj as CloudEventV1 | CloudEventV03);
|
return new CloudEvent(eventObj as CloudEventV1 | CloudEventV03, false);
|
||||||
|
|
||||||
// Validates the event
|
|
||||||
validateCloudEvent(cloudevent);
|
|
||||||
return cloudevent;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { expect } from "chai";
|
import { expect } from "chai";
|
||||||
import { CloudEvent, Version } from "../../src";
|
import { CloudEvent, ValidationError, Version } from "../../src";
|
||||||
import { CloudEventV03, CloudEventV1 } from "../../src/event/interfaces";
|
import { CloudEventV03, CloudEventV1 } from "../../src/event/interfaces";
|
||||||
|
|
||||||
const type = "org.cncf.cloudevents.example";
|
const type = "org.cncf.cloudevents.example";
|
||||||
|
@ -11,6 +11,7 @@ const fixture: CloudEventV1 = {
|
||||||
specversion: Version.V1,
|
specversion: Version.V1,
|
||||||
source,
|
source,
|
||||||
type,
|
type,
|
||||||
|
data: `"some data"`,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("A CloudEvent", () => {
|
describe("A CloudEvent", () => {
|
||||||
|
@ -20,6 +21,21 @@ describe("A CloudEvent", () => {
|
||||||
expect(ce.source).to.equal(source);
|
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()", () => {
|
it("serializes as JSON with toString()", () => {
|
||||||
const ce = new CloudEvent(fixture);
|
const ce = new CloudEvent(fixture);
|
||||||
expect(ce.toString()).to.deep.equal(JSON.stringify(ce));
|
expect(ce.toString()).to.deep.equal(JSON.stringify(ce));
|
||||||
|
@ -152,7 +168,7 @@ describe("A 1.0 CloudEvent", () => {
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
expect(err).to.be.instanceOf(TypeError);
|
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,
|
source: (null as unknown) as string,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
expect(err).to.be.instanceOf(TypeError);
|
expect(err).to.be.instanceOf(ValidationError);
|
||||||
expect(err.message).to.equal("invalid payload");
|
expect(err.message).to.include("invalid payload");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -27,19 +27,21 @@ const ext2Value = "acme";
|
||||||
const dataBinary = Uint32Array.from(JSON.stringify(data), (c) => c.codePointAt(0) as number);
|
const dataBinary = Uint32Array.from(JSON.stringify(data), (c) => c.codePointAt(0) as number);
|
||||||
const data_base64 = asBase64(dataBinary);
|
const data_base64 = asBase64(dataBinary);
|
||||||
|
|
||||||
describe("HTTP transport messages", () => {
|
describe("HTTP transport", () => {
|
||||||
it("can detect CloudEvent Messages", () => {
|
it("Can detect invalid CloudEvent Messages", () => {
|
||||||
// Create a message that is not an actual event
|
// Create a message that is not an actual event
|
||||||
let message: Message = {
|
const message: Message = {
|
||||||
body: "Hello world!",
|
body: "Hello world!",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-type": "text/plain",
|
"Content-type": "text/plain",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(HTTP.isEvent(message)).to.be.false;
|
expect(HTTP.isEvent(message)).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Can detect valid CloudEvent Messages", () => {
|
||||||
// Now create a message that is an event
|
// Now create a message that is an event
|
||||||
message = HTTP.binary(
|
const message = HTTP.binary(
|
||||||
new CloudEvent({
|
new CloudEvent({
|
||||||
source: "/message-test",
|
source: "/message-test",
|
||||||
type: "example",
|
type: "example",
|
||||||
|
@ -48,6 +50,41 @@ describe("HTTP transport messages", () => {
|
||||||
expect(HTTP.isEvent(message)).to.be.true;
|
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", () => {
|
describe("Specification version V1", () => {
|
||||||
const fixture: CloudEvent = new CloudEvent({
|
const fixture: CloudEvent = new CloudEvent({
|
||||||
specversion: Version.V1,
|
specversion: Version.V1,
|
||||||
|
|
Loading…
Reference in New Issue