From 08e98c7fe9851db420d1b748ec44ae131a277be5 Mon Sep 17 00:00:00 2001 From: Lance Ball Date: Wed, 26 Aug 2020 18:26:50 -0400 Subject: [PATCH 1/4] feat: introduce Message, Serializer, Deserializer and Binding interfaces (#324) * lib(messages): Implement a 4.0 Messages and other supporting interfaces This commit introduces the Message, Serializer and Deserializer, and Binding interfaces used to convert a CloudEvent into a Message that can be sent across a transport protocol. The first protocol implemented for this is HTTP, and some of the functionality formerly in src/transport/http has been simplified, reduced and/or moved to /src/messages/http. Test for V1 and V3 events are in place. Conformance tests have been modified to use these new interfaces vs. the HTTP Receiver class. Signed-off-by: Lance Ball --- examples/express-ex/index.js | 17 +- examples/express-ex/package.json | 2 +- src/event/cloudevent.ts | 6 + src/index.ts | 24 +- .../versions.ts => message/http/headers.ts} | 91 ++++ src/message/http/index.ts | 234 +++++++++ src/message/index.ts | 75 +++ src/transport/emitter.ts | 1 + src/transport/http/binary_emitter.ts | 3 +- src/transport/http/binary_receiver.ts | 94 ---- src/transport/http/headers.ts | 100 ---- src/transport/http/structured_receiver.ts | 91 ---- src/transport/receiver.ts | 100 +--- test/conformance/steps.ts | 21 +- test/integration/http_emitter_test.ts | 3 +- test/integration/message_test.ts | 202 ++++++++ test/integration/receiver_binary_03_tests.ts | 443 ----------------- test/integration/receiver_binary_1_tests.ts | 448 ------------------ .../receiver_structured_0_3_test.ts | 227 --------- .../integration/receiver_structured_1_test.ts | 182 ------- test/integration/sdk_test.ts | 7 +- 21 files changed, 668 insertions(+), 1703 deletions(-) rename src/{transport/http/versions.ts => message/http/headers.ts} (61%) create mode 100644 src/message/http/index.ts create mode 100644 src/message/index.ts delete mode 100644 src/transport/http/binary_receiver.ts delete mode 100644 src/transport/http/headers.ts delete mode 100644 src/transport/http/structured_receiver.ts create mode 100644 test/integration/message_test.ts delete mode 100644 test/integration/receiver_binary_03_tests.ts delete mode 100644 test/integration/receiver_binary_1_tests.ts delete mode 100644 test/integration/receiver_structured_0_3_test.ts delete mode 100644 test/integration/receiver_structured_1_test.ts diff --git a/examples/express-ex/index.js b/examples/express-ex/index.js index 57a71e1c..cfe68e95 100644 --- a/examples/express-ex/index.js +++ b/examples/express-ex/index.js @@ -1,8 +1,7 @@ -/* eslint-disable no-console */ +/* eslint-disable */ const express = require("express"); -const {Receiver} = require("cloudevents"); - +const { Receiver } = require("cloudevents"); const app = express(); app.use((req, res, next) => { @@ -25,8 +24,16 @@ app.post("/", (req, res) => { try { const event = Receiver.accept(req.headers, req.body); - console.log(`Accepted event: ${event}`); - res.status(201).json(event); + // respond as an event + const responseEventMessage = new CloudEvent({ + source: '/', + type: 'event:response', + ...event + }); + responseEventMessage.data = { + hello: 'world' + }; + res.status(201).json(responseEventMessage); } catch (err) { console.error(err); res.status(415).header("Content-Type", "application/json").send(JSON.stringify(err)); diff --git a/examples/express-ex/package.json b/examples/express-ex/package.json index 3d483f18..756d8f7b 100644 --- a/examples/express-ex/package.json +++ b/examples/express-ex/package.json @@ -14,7 +14,7 @@ "author": "fabiojose@gmail.com", "license": "Apache-2.0", "dependencies": { - "cloudevents": "~3.0.0", + "cloudevents": "^3.1.0", "express": "^4.17.1" } } diff --git a/src/event/cloudevent.ts b/src/event/cloudevent.ts index 2cb571a7..197b4bc1 100644 --- a/src/event/cloudevent.ts +++ b/src/event/cloudevent.ts @@ -156,6 +156,12 @@ export class CloudEvent implements CloudEventV1, CloudEventV03 { this.#_data = value; } + /** + * Used by JSON.stringify(). The name is confusing, but this method is called by + * JSON.stringify() when converting this object to JSON. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify + * @return {object} this event as a plain object + */ toJSON(): Record { const event = { ...this }; event.time = this.time; diff --git a/src/index.ts b/src/index.ts index 598d61c4..11149ff8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,9 @@ import { ValidationError } from "./event/validation"; import { CloudEventV03, CloudEventV03Attributes, CloudEventV1, CloudEventV1Attributes } from "./event/interfaces"; import { Emitter, TransportOptions } from "./transport/emitter"; -import { Receiver, Mode } from "./transport/receiver"; +import { Receiver } from "./transport/receiver"; import { Protocol } from "./transport/protocols"; -import { Headers, headersFor } from "./transport/http/headers"; +import { Headers, Mode, Binding, HTTP, Message, Serializer, Deserializer, headersFor } from "./message"; import CONSTANTS from "./constants"; @@ -18,14 +18,20 @@ export { CloudEventV1Attributes, Version, ValidationError, - // From transport - Emitter, - Receiver, - Mode, - Protocol, - TransportOptions, + // From message Headers, - headersFor, + Mode, + Binding, + Message, + Deserializer, + Serializer, + headersFor, // TODO: Deprecated. Remove for 4.0 + HTTP, + // From transport + Emitter, // TODO: Deprecated. Remove for 4.0 + Receiver, // TODO: Deprecated. Remove for 4.0 + Protocol, // TODO: Deprecated. Remove for 4.0 + TransportOptions, // TODO: Deprecated. Remove for 4.0 // From Constants CONSTANTS, }; diff --git a/src/transport/http/versions.ts b/src/message/http/headers.ts similarity index 61% rename from src/transport/http/versions.ts rename to src/message/http/headers.ts index 70ddc560..228445cb 100644 --- a/src/transport/http/versions.ts +++ b/src/message/http/headers.ts @@ -1,6 +1,97 @@ import { PassThroughParser, DateParser, MappedParser } from "../../parsers"; +import { ValidationError, CloudEvent } from "../.."; +import { Headers } from "../"; +import { Version } from "../../event/cloudevent"; import CONSTANTS from "../../constants"; +export const allowedContentTypes = [CONSTANTS.DEFAULT_CONTENT_TYPE, CONSTANTS.MIME_JSON, CONSTANTS.MIME_OCTET_STREAM]; +export const requiredHeaders = [ + CONSTANTS.CE_HEADERS.ID, + CONSTANTS.CE_HEADERS.SOURCE, + CONSTANTS.CE_HEADERS.TYPE, + 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 + * and that is "ce-id", corresponding to the event ID. + * @param {CloudEvent} event a CloudEvent + * @returns {Object} the headers that will be sent for the event + */ +export function headersFor(event: CloudEvent): Headers { + const headers: Headers = {}; + let headerMap: Readonly<{ [key: string]: MappedParser }>; + if (event.specversion === Version.V1) { + headerMap = v1headerMap; + } else { + headerMap = v03headerMap; + } + + // iterate over the event properties - generate a header for each + Object.getOwnPropertyNames(event).forEach((property) => { + const value = event[property]; + if (value) { + const map: MappedParser | undefined = headerMap[property] as MappedParser; + if (map) { + headers[map.name] = map.parser.parse(value as string) as string; + } else if (property !== CONSTANTS.DATA_ATTRIBUTE && property !== `${CONSTANTS.DATA_ATTRIBUTE}_base64`) { + headers[`${CONSTANTS.EXTENSIONS_PREFIX}${property}`] = value as string; + } + } + }); + // Treat time specially, since it's handled with getters and setters in CloudEvent + if (event.time) { + headers[CONSTANTS.CE_HEADERS.TIME] = event.time as string; + } + return headers; +} + +/** + * Sanitizes incoming headers by lowercasing them and potentially removing + * encoding from the content-type header. + * @param {Headers} headers HTTP headers as key/value pairs + * @returns {Headers} the sanitized headers + */ +export function sanitize(headers: Headers): Headers { + const sanitized: Headers = {}; + + Array.from(Object.keys(headers)) + .filter((header) => Object.hasOwnProperty.call(headers, header)) + .forEach((header) => (sanitized[header.toLowerCase()] = headers[header])); + + return sanitized; +} + function parser(name: string, parser = new PassThroughParser()): MappedParser { return { name: name, parser: parser }; } diff --git a/src/message/http/index.ts b/src/message/http/index.ts new file mode 100644 index 00000000..c93917de --- /dev/null +++ b/src/message/http/index.ts @@ -0,0 +1,234 @@ +import { CloudEvent, CloudEventV03, CloudEventV1, CONSTANTS, Mode, Version } from "../.."; +import { Message, Headers } from ".."; + +import { headersFor, sanitize, v03structuredParsers, v1binaryParsers, v1structuredParsers, validate } 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 +export function binary(event: CloudEvent): Message { + const contentType: Headers = { [CONSTANTS.HEADER_CONTENT_TYPE]: CONSTANTS.DEFAULT_CONTENT_TYPE }; + const headers: Headers = headersFor(event); + return { + headers: { ...contentType, ...headers }, + body: asData(event.data, event.datacontenttype as string), + }; +} + +// implements Serializer +export function structured(event: CloudEvent): Message { + return { + headers: { + [CONSTANTS.HEADER_CONTENT_TYPE]: CONSTANTS.DEFAULT_CE_CONTENT_TYPE, + }, + body: event.toString(), + }; +} + +// implements Detector +// TODO: this could probably be optimized +export function isEvent(message: Message): boolean { + try { + deserialize(message); + return true; + } catch (err) { + return false; + } +} + +/** + * Converts a Message to a CloudEvent + * + * @param {Message} message the incoming message + * @return {CloudEvent} A new {CloudEvent} instance + */ +export function deserialize(message: Message): CloudEvent { + const cleanHeaders: Headers = sanitize(message.headers); + const mode: Mode = getMode(cleanHeaders); + let version = getVersion(mode, cleanHeaders, message.body); + if (version !== Version.V03 && version !== Version.V1) { + console.error(`Unknown spec version ${version}. Default to ${Version.V1}`); + version = Version.V1; + } + switch (mode) { + case Mode.BINARY: + return parseBinary(message, version); + case Mode.STRUCTURED: + return parseStructured(message, version); + default: + throw new ValidationError("Unknown Message mode"); + } +} + +/** + * Determines the HTTP transport mode (binary or structured) based + * on the incoming HTTP headers. + * @param {Headers} headers the incoming HTTP headers + * @returns {Mode} the transport mode + */ +function getMode(headers: Headers): Mode { + const contentType = headers[CONSTANTS.HEADER_CONTENT_TYPE]; + if (contentType && contentType.startsWith(CONSTANTS.MIME_CE)) { + return Mode.STRUCTURED; + } + if (headers[CONSTANTS.CE_HEADERS.ID]) { + return Mode.BINARY; + } + throw new ValidationError("no cloud event detected"); +} + +/** + * Determines the version of an incoming CloudEvent based on the + * HTTP headers or HTTP body, depending on transport mode. + * @param {Mode} mode the HTTP transport mode + * @param {Headers} headers the incoming HTTP headers + * @param {Record} body the HTTP request body + * @returns {Version} the CloudEvent specification version + */ +function getVersion(mode: Mode, headers: Headers, body: string | Record) { + if (mode === Mode.BINARY) { + // Check the headers for the version + const versionHeader = headers[CONSTANTS.CE_HEADERS.SPEC_VERSION]; + if (versionHeader) { + return versionHeader; + } + } else { + // structured mode - the version is in the body + return typeof body === "string" ? JSON.parse(body).specversion : (body as CloudEvent).specversion; + } + return Version.V1; +} + +/** + * Parses an incoming HTTP Message, converting it to a {CloudEvent} + * instance if it conforms to the Cloud Event specification for this receiver. + * + * @param {Message} message the incoming HTTP Message + * @param {Version} version the spec version of the incoming event + * @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 { + const headers = message.headers; + let body = message.body; + + if (!headers) throw new ValidationError("headers is null or undefined"); + if (body) { + isStringOrObjectOrThrow(body, new ValidationError("payload must be an object or a string")); + } + + if ( + headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] && + headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] !== Version.V03 && + headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] !== Version.V1 + ) { + throw new ValidationError(`invalid spec version ${headers[CONSTANTS.CE_HEADERS.SPEC_VERSION]}`); + } + + 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 eventObj: { [key: string]: unknown | string | Record } = {}; + const parserMap: Record = version === Version.V1 ? v1binaryParsers : v1binaryParsers; + + for (const header in parserMap) { + if (sanitizedHeaders[header]) { + const mappedParser: MappedParser = parserMap[header]; + eventObj[mappedParser.name] = mappedParser.parser.parse(sanitizedHeaders[header]); + delete sanitizedHeaders[header]; + } + } + + let parsedPayload; + + if (body) { + const parser = parserByContentType[eventObj.datacontenttype as string]; + if (!parser) { + throw new ValidationError(`no parser found for content type ${eventObj.datacontenttype}`); + } + parsedPayload = parser.parse(body); + } + + // Every unprocessed header can be an extension + for (const header in sanitizedHeaders) { + if (header.startsWith(CONSTANTS.EXTENSIONS_PREFIX)) { + eventObj[header.substring(CONSTANTS.EXTENSIONS_PREFIX.length)] = headers[header]; + } + } + // At this point, if the datacontenttype is application/json and the datacontentencoding is base64 + // then the data has already been decoded as a string, then parsed as JSON. We don't need to have + // the datacontentencoding property set - in fact, it's incorrect to do so. + if (eventObj.datacontenttype === CONSTANTS.MIME_JSON && eventObj.datacontentencoding === CONSTANTS.ENCODING_BASE64) { + delete eventObj.datacontentencoding; + } + + const cloudevent = new CloudEvent({ ...eventObj, data: parsedPayload } as CloudEventV1 | CloudEventV03); + validateCloudEvent(cloudevent); + return cloudevent; +} + +/** + * Creates a new CloudEvent instance based on the provided payload and headers. + * + * @param {Message} message the incoming Message + * @param {Version} version the spec version of this message (v1 or v03) + * @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 { + const payload = message.body; + const headers = message.headers; + + if (!payload) throw new ValidationError("payload is null or undefined"); + if (!headers) throw new ValidationError("headers is null or undefined"); + isStringOrObjectOrThrow(payload, new ValidationError("payload must be an object or a string")); + + if ( + headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] && + headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] != Version.V03 && + headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] != Version.V1 + ) { + throw new ValidationError(`invalid spec version ${headers[CONSTANTS.CE_HEADERS.SPEC_VERSION]}`); + } + + // Clone and low case all headers names + const sanitizedHeaders = sanitize(headers); + + const contentType = sanitizedHeaders[CONSTANTS.HEADER_CONTENT_TYPE]; + const parser: Parser = contentType ? parserByContentType[contentType] : new JSONParser(); + if (!parser) throw new ValidationError(`invalid content type ${sanitizedHeaders[CONSTANTS.HEADER_CONTENT_TYPE]}`); + const incoming = { ...(parser.parse(payload) as Record) }; + + const eventObj: { [key: string]: unknown } = {}; + const parserMap: Record = version === Version.V1 ? v1structuredParsers : v03structuredParsers; + + for (const key in parserMap) { + const property = incoming[key]; + if (property) { + const parser: MappedParser = parserMap[key]; + eventObj[parser.name] = parser.parser.parse(property as string); + } + delete incoming[key]; + } + + // extensions are what we have left after processing all other properties + for (const key in incoming) { + eventObj[key] = incoming[key]; + } + + // ensure data content is correctly decoded + if (eventObj.data_base64) { + const parser = new Base64Parser(); + eventObj.data = JSON.parse(parser.parse(eventObj.data_base64 as string)); + delete eventObj.data_base64; + delete eventObj.datacontentencoding; + } + const cloudevent = new CloudEvent(eventObj as CloudEventV1 | CloudEventV03); + + // Validates the event + validateCloudEvent(cloudevent); + return cloudevent; +} diff --git a/src/message/index.ts b/src/message/index.ts new file mode 100644 index 00000000..14ed270c --- /dev/null +++ b/src/message/index.ts @@ -0,0 +1,75 @@ +import { CloudEvent } from ".."; +import { binary, deserialize, structured, isEvent } from "./http"; +import { headersFor } from "./http/headers"; + +/** + * Binding is an interface for transport protocols to implement, + * which provides functions for sending CloudEvent Messages over + * the wire. + */ +export interface Binding { + binary: Serializer; + structured: Serializer; + toEvent: Deserializer; + isEvent: Detector; +} + +/** + * Headers is an interface representing transport-agnostic headers as + * key/value string pairs + */ +export interface Headers { + [key: string]: string; +} + +/** + * Message is an interface representing a CloudEvent as a + * transport-agnostic message + */ +export interface Message { + headers: Headers; + body: string; +} + +/** + * An enum representing the two transport modes, binary and structured + */ +export enum Mode { + BINARY = "binary", + STRUCTURED = "structured", +} + +/** + * Serializer is an interface for functions that can convert a + * CloudEvent into a Message. + */ +export interface Serializer { + (event: CloudEvent): Message; +} + +/** + * Deserializer is a function interface that converts a + * Message to a CloudEvent + */ +export interface Deserializer { + (message: Message): CloudEvent; +} + +/** + * Detector is a function interface that detects whether + * a message contains a valid CloudEvent + */ +export interface Detector { + (message: Message): boolean; +} + +// HTTP Message capabilities +export const HTTP: Binding = { + binary: binary as Serializer, + structured: structured as Serializer, + toEvent: deserialize as Deserializer, + isEvent: isEvent as Detector, +}; + +// TODO: Deprecated. Remove this for 4.0 +export { headersFor }; diff --git a/src/transport/emitter.ts b/src/transport/emitter.ts index 911339e3..ad1b8a7c 100644 --- a/src/transport/emitter.ts +++ b/src/transport/emitter.ts @@ -63,6 +63,7 @@ export class Emitter { * In that case, it will be used as the recipient endpoint. The endpoint can * be overridden by providing a URL here. * @returns {Promise} Promise with an eventual response from the receiver + * @deprecated Will be removed in 4.0.0. Consider using the Message interface with HTTP.[binary|structured](event) */ send(event: CloudEvent, options?: TransportOptions): Promise { options = options || {}; diff --git a/src/transport/http/binary_emitter.ts b/src/transport/http/binary_emitter.ts index 20f51662..cabf9066 100644 --- a/src/transport/http/binary_emitter.ts +++ b/src/transport/http/binary_emitter.ts @@ -2,7 +2,8 @@ import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; import { CloudEvent, Version } from "../../event/cloudevent"; import { TransportOptions } from "../emitter"; -import { Headers, headersFor } from "./headers"; +import { Headers } from "../../message"; +import { headersFor } from "../../message/http/headers"; import { asData } from "../../event/validation"; import CONSTANTS from "../../constants"; diff --git a/src/transport/http/binary_receiver.ts b/src/transport/http/binary_receiver.ts deleted file mode 100644 index aa027f2b..00000000 --- a/src/transport/http/binary_receiver.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { CloudEvent, Version } from "../.."; -import { CloudEventV1, CloudEventV03 } from "../../event/interfaces"; -import { validateCloudEvent } from "../../event/spec"; -import { Headers, validate } from "./headers"; -import { v03binaryParsers, v1binaryParsers } from "./versions"; -import { parserByContentType, MappedParser } from "../../parsers"; -import { isString, isBase64, ValidationError, isStringOrObjectOrThrow } from "../../event/validation"; -import CONSTANTS from "../../constants"; - -/** - * A class that receives binary CloudEvents over HTTP. This class can be used - * if you know that all incoming events will be using binary transport. If - * events can come as either binary or structured, use {HTTPReceiver}. - */ -export class BinaryHTTPReceiver { - /** - * The specification version of the incoming cloud event - */ - version: Version; - constructor(version: Version = Version.V1) { - this.version = version; - } - - /** - * Parses an incoming HTTP request, converting it to a {CloudEvent} - * instance if it conforms to the Cloud Event specification for this receiver. - * - * @param {Object|string} payload the HTTP request body - * @param {Object} headers the HTTP request headers - * @param {Version} version the spec version of the incoming event - * @returns {CloudEvent} an instance of CloudEvent representing the incoming request - * @throws {ValidationError} of the event does not conform to the spec - */ - parse(payload: string | Record | undefined | null, headers: Headers): CloudEvent { - if (!headers) throw new ValidationError("headers is null or undefined"); - if (payload) { - isStringOrObjectOrThrow(payload, new ValidationError("payload must be an object or a string")); - } - - if ( - headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] && - headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] !== Version.V03 && - headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] !== Version.V1 - ) { - throw new ValidationError(`invalid spec version ${headers[CONSTANTS.CE_HEADERS.SPEC_VERSION]}`); - } - - payload = isString(payload) && isBase64(payload) ? Buffer.from(payload as string, "base64").toString() : payload; - - // Clone and low case all headers names - const sanitizedHeaders = validate(headers); - - const eventObj: { [key: string]: unknown | string | Record } = {}; - const parserMap: Record = this.version === Version.V1 ? v1binaryParsers : v03binaryParsers; - - for (const header in parserMap) { - if (sanitizedHeaders[header]) { - const mappedParser: MappedParser = parserMap[header]; - eventObj[mappedParser.name] = mappedParser.parser.parse(sanitizedHeaders[header]); - delete sanitizedHeaders[header]; - } - } - - let parsedPayload; - - if (payload) { - const parser = parserByContentType[eventObj.datacontenttype as string]; - if (!parser) { - throw new ValidationError(`no parser found for content type ${eventObj.datacontenttype}`); - } - parsedPayload = parser.parse(payload); - } - - // Every unprocessed header can be an extension - for (const header in sanitizedHeaders) { - if (header.startsWith(CONSTANTS.EXTENSIONS_PREFIX)) { - eventObj[header.substring(CONSTANTS.EXTENSIONS_PREFIX.length)] = headers[header]; - } - } - // At this point, if the datacontenttype is application/json and the datacontentencoding is base64 - // then the data has already been decoded as a string, then parsed as JSON. We don't need to have - // the datacontentencoding property set - in fact, it's incorrect to do so. - if ( - eventObj.datacontenttype === CONSTANTS.MIME_JSON && - eventObj.datacontentencoding === CONSTANTS.ENCODING_BASE64 - ) { - delete eventObj.datacontentencoding; - } - - const cloudevent = new CloudEvent({ ...eventObj, data: parsedPayload } as CloudEventV1 | CloudEventV03); - validateCloudEvent(cloudevent); - return cloudevent; - } -} diff --git a/src/transport/http/headers.ts b/src/transport/http/headers.ts deleted file mode 100644 index 044aef74..00000000 --- a/src/transport/http/headers.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ValidationError, CloudEvent } from "../.."; -import { v03headerMap, v1headerMap } from "./versions"; -import { Version } from "../../event/cloudevent"; -import { MappedParser } from "../../parsers"; -import CONSTANTS from "../../constants"; - -/** - * An interface representing HTTP headers as key/value string pairs - */ -export interface Headers { - [key: string]: string; -} - -export const allowedContentTypes = [CONSTANTS.DEFAULT_CONTENT_TYPE, CONSTANTS.MIME_JSON, CONSTANTS.MIME_OCTET_STREAM]; -export const requiredHeaders = [ - CONSTANTS.CE_HEADERS.ID, - CONSTANTS.CE_HEADERS.SOURCE, - CONSTANTS.CE_HEADERS.TYPE, - 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 - * and that is "ce-id", corresponding to the event ID. - * @param {CloudEvent} event a CloudEvent - * @returns {Object} the headers that will be sent for the event - */ -export function headersFor(event: CloudEvent): Headers { - const headers: Headers = {}; - let headerMap: Readonly<{ [key: string]: MappedParser }>; - if (event.specversion === Version.V1) { - headerMap = v1headerMap; - } else { - headerMap = v03headerMap; - } - - // iterate over the event properties - generate a header for each - Object.getOwnPropertyNames(event).forEach((property) => { - const value = event[property]; - if (value) { - const map: MappedParser | undefined = headerMap[property] as MappedParser; - if (map) { - headers[map.name] = map.parser.parse(value as string) as string; - } else if (property !== CONSTANTS.DATA_ATTRIBUTE && property !== `${CONSTANTS.DATA_ATTRIBUTE}_base64`) { - headers[`${CONSTANTS.EXTENSIONS_PREFIX}${property}`] = value as string; - } - } - }); - // Treat time specially, since it's handled with getters and setters in CloudEvent - if (event.time) { - headers[CONSTANTS.CE_HEADERS.TIME] = event.time as string; - } - return headers; -} - -/** - * Sanitizes incoming headers by lowercasing them and potentially removing - * encoding from the content-type header. - * @param {Headers} headers HTTP headers as key/value pairs - * @returns {Headers} the sanitized headers - */ -export function sanitize(headers: Headers): Headers { - const sanitized: Headers = {}; - - Array.from(Object.keys(headers)) - .filter((header) => Object.hasOwnProperty.call(headers, header)) - .forEach((header) => (sanitized[header.toLowerCase()] = headers[header])); - - return sanitized; -} diff --git a/src/transport/http/structured_receiver.ts b/src/transport/http/structured_receiver.ts deleted file mode 100644 index ef9b3a34..00000000 --- a/src/transport/http/structured_receiver.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { CloudEvent, Version } from "../.."; -import { Headers, sanitize } from "./headers"; -import { Parser, JSONParser, MappedParser, Base64Parser } from "../../parsers"; -import { parserByContentType } from "../../parsers"; -import { v1structuredParsers, v03structuredParsers } from "./versions"; -import { isString, isBase64, ValidationError, isStringOrObjectOrThrow } from "../../event/validation"; -import { CloudEventV1, CloudEventV03 } from "../../event/interfaces"; -import { validateCloudEvent } from "../../event/spec"; -import CONSTANTS from "../../constants"; - -/** - * A utility class used to receive structured CloudEvents - * over HTTP. - * @see {StructuredReceiver} - */ -export class StructuredHTTPReceiver { - /** - * The specification version of the incoming cloud event - */ - version: Version; - constructor(version: Version = Version.V1) { - this.version = version; - } - - /** - * Creates a new CloudEvent instance based on the provided payload and headers. - * - * @param {object} payload the cloud event data payload - * @param {object} headers the HTTP headers received for this cloud event - * @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 - */ - parse(payload: Record | string | undefined | null, headers: Headers): CloudEvent { - if (!payload) throw new ValidationError("payload is null or undefined"); - if (!headers) throw new ValidationError("headers is null or undefined"); - isStringOrObjectOrThrow(payload, new ValidationError("payload must be an object or a string")); - - if ( - headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] && - headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] != Version.V03 && - headers[CONSTANTS.CE_HEADERS.SPEC_VERSION] != Version.V1 - ) { - throw new ValidationError(`invalid spec version ${headers[CONSTANTS.CE_HEADERS.SPEC_VERSION]}`); - } - - payload = isString(payload) && isBase64(payload) ? Buffer.from(payload as string, "base64").toString() : payload; - - // Clone and low case all headers names - const sanitizedHeaders = sanitize(headers); - - const contentType = sanitizedHeaders[CONSTANTS.HEADER_CONTENT_TYPE]; - const parser: Parser = contentType ? parserByContentType[contentType] : new JSONParser(); - if (!parser) throw new ValidationError(`invalid content type ${sanitizedHeaders[CONSTANTS.HEADER_CONTENT_TYPE]}`); - const incoming = { ...(parser.parse(payload) as Record) }; - - const eventObj: { [key: string]: unknown } = {}; - const parserMap: Record = - this.version === Version.V1 ? v1structuredParsers : v03structuredParsers; - - for (const key in parserMap) { - const property = incoming[key]; - if (property) { - const parser: MappedParser = parserMap[key]; - eventObj[parser.name] = parser.parser.parse(property as string); - } - delete incoming[key]; - } - - // extensions are what we have left after processing all other properties - for (const key in incoming) { - eventObj[key] = incoming[key]; - } - - // ensure data content is correctly encoded - if (eventObj.data && eventObj.datacontentencoding) { - if (eventObj.datacontentencoding === CONSTANTS.ENCODING_BASE64 && !isBase64(eventObj.data)) { - throw new ValidationError("invalid payload"); - } else if (eventObj.datacontentencoding === CONSTANTS.ENCODING_BASE64) { - const dataParser = new Base64Parser(); - eventObj.data = JSON.parse(dataParser.parse(eventObj.data as string)); - delete eventObj.datacontentencoding; - } - } - - const cloudevent = new CloudEvent(eventObj as CloudEventV1 | CloudEventV03); - - // Validates the event - validateCloudEvent(cloudevent); - return cloudevent; - } -} diff --git a/src/transport/receiver.ts b/src/transport/receiver.ts index 5c83ec57..f15e8f4c 100644 --- a/src/transport/receiver.ts +++ b/src/transport/receiver.ts @@ -1,33 +1,11 @@ -import { Headers, sanitize } from "./http/headers"; -import { CloudEvent, Version, ValidationError } from ".."; -import { BinaryHTTPReceiver as BinaryReceiver } from "./http/binary_receiver"; -import { StructuredHTTPReceiver as StructuredReceiver } from "./http/structured_receiver"; -import { CloudEventV1, CloudEventV03 } from "../event/interfaces"; -import CONSTANTS from "../constants"; - -/** - * An enum representing the two HTTP transport modes, binary and structured - */ -export enum Mode { - BINARY = "binary", - STRUCTURED = "structured", -} - -const receivers = { - v1: { - structured: new StructuredReceiver(Version.V1), - binary: new BinaryReceiver(Version.V1), - }, - v03: { - structured: new StructuredReceiver(Version.V03), - binary: new BinaryReceiver(Version.V03), - }, -}; +import { Headers, Message, HTTP } from "../message"; +import { sanitize } from "../message/http/headers"; +import { CloudEvent } from ".."; /** * A class to receive a CloudEvent from an HTTP POST request. */ -export class Receiver { +export const Receiver = { /** * Acceptor for an incoming HTTP CloudEvent POST. Can process * binary and structured incoming CloudEvents. @@ -35,65 +13,15 @@ export class Receiver { * @param {Object} headers HTTP headers keyed by header name ("Content-Type") * @param {Object|JSON} body The body of the HTTP request * @return {CloudEvent} A new {CloudEvent} instance + * @deprecated Will be removed in 4.0.0. Consider using the Message interface with HTTP.toEvent(message) */ - static accept( - headers: Headers, - body: string | Record | CloudEventV1 | CloudEventV03 | undefined | null, - ): CloudEvent { + accept(headers: Headers, body: string | Record | undefined | null): CloudEvent { const cleanHeaders: Headers = sanitize(headers); - const mode: Mode = getMode(cleanHeaders); - const version = getVersion(mode, cleanHeaders, body); - switch (version) { - case Version.V1: - return receivers.v1[mode].parse(body, headers); - case Version.V03: - return receivers.v03[mode].parse(body, headers); - default: - console.error(`Unknown spec version ${version}. Default to ${Version.V1}`); - return receivers.v1[mode].parse(body, headers); - } - } -} - -/** - * Determines the HTTP transport mode (binary or structured) based - * on the incoming HTTP headers. - * @param {Headers} headers the incoming HTTP headers - * @returns {Mode} the transport mode - */ -function getMode(headers: Headers): Mode { - const contentType = headers[CONSTANTS.HEADER_CONTENT_TYPE]; - if (contentType && contentType.startsWith(CONSTANTS.MIME_CE)) { - return Mode.STRUCTURED; - } - if (headers[CONSTANTS.CE_HEADERS.ID]) { - return Mode.BINARY; - } - throw new ValidationError("no cloud event detected"); -} - -/** - * Determines the version of an incoming CloudEvent based on the - * HTTP headers or HTTP body, depending on transport mode. - * @param {Mode} mode the HTTP transport mode - * @param {Headers} headers the incoming HTTP headers - * @param {Record} body the HTTP request body - * @returns {Version} the CloudEvent specification version - */ -function getVersion( - mode: Mode, - headers: Headers, - body: string | Record | CloudEventV03 | CloudEventV1 | undefined | null, -) { - if (mode === Mode.BINARY) { - // Check the headers for the version - const versionHeader = headers[CONSTANTS.CE_HEADERS.SPEC_VERSION]; - if (versionHeader) { - return versionHeader; - } - } else { - // structured mode - the version is in the body - return typeof body === "string" ? JSON.parse(body).specversion : (body as CloudEvent).specversion; - } - return Version.V1; -} + const cleanBody = body ? (typeof body === "object" ? JSON.stringify(body) : body) : ""; + const message: Message = { + headers: cleanHeaders, + body: cleanBody, + }; + return HTTP.toEvent(message); + }, +}; diff --git a/test/conformance/steps.ts b/test/conformance/steps.ts index f6e57bc6..8b3377de 100644 --- a/test/conformance/steps.ts +++ b/test/conformance/steps.ts @@ -1,8 +1,7 @@ -/* eslint-disable no-console */ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { assert } from "chai"; import { Given, When, Then, World } from "cucumber"; -import { Receiver } from "../../src"; +import { Message, Headers, HTTP } from "../../src"; // eslint-disable-next-line @typescript-eslint/no-var-requires const { HTTPParser } = require("http-parser-js"); @@ -14,20 +13,24 @@ Given("HTTP Protocol Binding is supported", function (this: World) { }); Given("an HTTP request", function (request: string) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const world = this; + // Create a Message from the incoming HTTP request + const message: Message = { + headers: {}, + body: "", + }; parser.onHeadersComplete = function (record: Record) { - world.headers = arrayToObject(record.headers); + message.headers = extractHeaders(record.headers); }; parser.onBody = function (body: Buffer, offset: number) { - world.body = body.slice(offset).toString(); + message.body = body.slice(offset).toString(); }; + this.message = message; parser.execute(Buffer.from(request), 0, request.length); return true; }); When("parsed as HTTP request", function () { - this.cloudevent = Receiver.accept(this.headers, this.body); + this.cloudevent = HTTP.toEvent(this.message); return true; }); @@ -47,8 +50,8 @@ Then("the data is equal to the following JSON:", function (json: string) { return true; }); -function arrayToObject(arr: []): Record { - const obj: Record = {}; +function extractHeaders(arr: []): Headers { + const obj: Headers = {}; // @ts-ignore return arr.reduce(({}, keyOrValue, index, arr) => { if (index % 2 === 0) { diff --git a/test/integration/http_emitter_test.ts b/test/integration/http_emitter_test.ts index 3c8a2034..e9f184bc 100644 --- a/test/integration/http_emitter_test.ts +++ b/test/integration/http_emitter_test.ts @@ -6,7 +6,8 @@ import CONSTANTS from "../../src/constants"; const DEFAULT_CE_CONTENT_TYPE = CONSTANTS.DEFAULT_CE_CONTENT_TYPE; const DEFAULT_CONTENT_TYPE = CONSTANTS.DEFAULT_CONTENT_TYPE; -import { CloudEvent, Version, Emitter, Protocol, headersFor } from "../../src"; +import { CloudEvent, Version, Emitter, Protocol } from "../../src"; +import { headersFor } from "../../src/message/http/headers"; import { AxiosResponse } from "axios"; const receiver = "https://cloudevents.io/"; diff --git a/test/integration/message_test.ts b/test/integration/message_test.ts new file mode 100644 index 00000000..c6a3315f --- /dev/null +++ b/test/integration/message_test.ts @@ -0,0 +1,202 @@ +import { expect } from "chai"; +import { CloudEvent, CONSTANTS, Version } from "../../src"; +import { asBase64 } from "../../src/event/validation"; +import { Message, HTTP } from "../../src/message"; + +const type = "org.cncf.cloudevents.example"; +const source = "urn:event:from:myapi/resource/123"; +const time = new Date(); +const subject = "subject.ext"; +const dataschema = "http://cloudevents.io/schema.json"; +const datacontenttype = "application/json"; +const id = "b46cf653-d48a-4b90-8dfa-355c01061361"; +const data = { + foo: "bar", +}; + +// Attributes for v03 events +const schemaurl = "https://cloudevents.io/schema.json"; +const datacontentencoding = "base64"; + +const ext1Name = "extension1"; +const ext1Value = "foobar"; +const ext2Name = "extension2"; +const ext2Value = "acme"; + +// Binary data as base64 +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", () => { + // Create a message that is not an actual event + let message: Message = { + body: "Hello world!", + headers: { + "Content-type": "text/plain", + }, + }; + expect(HTTP.isEvent(message)).to.be.false; + + // Now create a message that is an event + message = HTTP.binary( + new CloudEvent({ + source: "/message-test", + type: "example", + }), + ); + expect(HTTP.isEvent(message)).to.be.true; + }); + + describe("Specification version V1", () => { + const fixture: CloudEvent = new CloudEvent({ + specversion: Version.V1, + id, + type, + source, + datacontenttype, + subject, + time, + dataschema, + data, + [ext1Name]: ext1Value, + [ext2Name]: ext2Value, + }); + + it("Binary Messages can be created from a CloudEvent", () => { + const message: Message = HTTP.binary(fixture); + expect(message.body).to.equal(data); + // validate all headers + expect(message.headers[CONSTANTS.HEADER_CONTENT_TYPE]).to.equal(datacontenttype); + expect(message.headers[CONSTANTS.CE_HEADERS.SPEC_VERSION]).to.equal(Version.V1); + expect(message.headers[CONSTANTS.CE_HEADERS.ID]).to.equal(id); + expect(message.headers[CONSTANTS.CE_HEADERS.TYPE]).to.equal(type); + expect(message.headers[CONSTANTS.CE_HEADERS.SOURCE]).to.equal(source); + expect(message.headers[CONSTANTS.CE_HEADERS.SUBJECT]).to.equal(subject); + expect(message.headers[CONSTANTS.CE_HEADERS.TIME]).to.equal(fixture.time); + expect(message.headers[CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]).to.equal(dataschema); + expect(message.headers[`ce-${ext1Name}`]).to.equal(ext1Value); + expect(message.headers[`ce-${ext2Name}`]).to.equal(ext2Value); + }); + + it("Structured Messages can be created from a CloudEvent", () => { + const message: Message = HTTP.structured(fixture); + expect(message.headers[CONSTANTS.HEADER_CONTENT_TYPE]).to.equal(CONSTANTS.DEFAULT_CE_CONTENT_TYPE); + // Parse the message body as JSON, then validate the attributes + const body = JSON.parse(message.body); + expect(body[CONSTANTS.CE_ATTRIBUTES.SPEC_VERSION]).to.equal(Version.V1); + expect(body[CONSTANTS.CE_ATTRIBUTES.ID]).to.equal(id); + expect(body[CONSTANTS.CE_ATTRIBUTES.TYPE]).to.equal(type); + expect(body[CONSTANTS.CE_ATTRIBUTES.SOURCE]).to.equal(source); + expect(body[CONSTANTS.CE_ATTRIBUTES.SUBJECT]).to.equal(subject); + expect(body[CONSTANTS.CE_ATTRIBUTES.TIME]).to.equal(fixture.time); + expect(body[CONSTANTS.STRUCTURED_ATTRS_1.DATA_SCHEMA]).to.equal(dataschema); + expect(body[ext1Name]).to.equal(ext1Value); + expect(body[ext2Name]).to.equal(ext2Value); + }); + + it("A CloudEvent can be converted from a binary Message", () => { + const message = HTTP.binary(fixture); + const event = HTTP.toEvent(message); + expect(event).to.deep.equal(fixture); + }); + + it("A CloudEvent can be converted from a structured Message", () => { + const message = HTTP.structured(fixture); + const event = HTTP.toEvent(message); + expect(event).to.deep.equal(fixture); + }); + + it("Supports Base-64 encoded data in structured messages", () => { + const event = fixture.cloneWith({ data: dataBinary }); + expect(event.data_base64).to.equal(data_base64); + const message = HTTP.structured(event); + const eventDeserialized = HTTP.toEvent(message); + expect(eventDeserialized.data).to.deep.equal({ foo: "bar" }); + }); + + it("Supports Base-64 encoded data in binary messages", () => { + const event = fixture.cloneWith({ data: dataBinary }); + expect(event.data_base64).to.equal(data_base64); + const message = HTTP.binary(event); + const eventDeserialized = HTTP.toEvent(message); + expect(eventDeserialized.data).to.deep.equal({ foo: "bar" }); + }); + }); + + describe("Specification version V03", () => { + const fixture: CloudEvent = new CloudEvent({ + specversion: Version.V03, + id, + type, + source, + datacontenttype, + subject, + time, + schemaurl, + data, + [ext1Name]: ext1Value, + [ext2Name]: ext2Value, + }); + + it("Binary Messages can be created from a CloudEvent", () => { + const message: Message = HTTP.binary(fixture); + expect(message.body).to.equal(data); + // validate all headers + expect(message.headers[CONSTANTS.HEADER_CONTENT_TYPE]).to.equal(datacontenttype); + expect(message.headers[CONSTANTS.CE_HEADERS.SPEC_VERSION]).to.equal(Version.V03); + expect(message.headers[CONSTANTS.CE_HEADERS.ID]).to.equal(id); + expect(message.headers[CONSTANTS.CE_HEADERS.TYPE]).to.equal(type); + expect(message.headers[CONSTANTS.CE_HEADERS.SOURCE]).to.equal(source); + expect(message.headers[CONSTANTS.CE_HEADERS.SUBJECT]).to.equal(subject); + expect(message.headers[CONSTANTS.CE_HEADERS.TIME]).to.equal(fixture.time); + expect(message.headers[CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]).to.equal(schemaurl); + expect(message.headers[`ce-${ext1Name}`]).to.equal(ext1Value); + expect(message.headers[`ce-${ext2Name}`]).to.equal(ext2Value); + }); + + it("Structured Messages can be created from a CloudEvent", () => { + const message: Message = HTTP.structured(fixture); + expect(message.headers[CONSTANTS.HEADER_CONTENT_TYPE]).to.equal(CONSTANTS.DEFAULT_CE_CONTENT_TYPE); + // Parse the message body as JSON, then validate the attributes + const body = JSON.parse(message.body); + expect(body[CONSTANTS.CE_ATTRIBUTES.SPEC_VERSION]).to.equal(Version.V03); + expect(body[CONSTANTS.CE_ATTRIBUTES.ID]).to.equal(id); + expect(body[CONSTANTS.CE_ATTRIBUTES.TYPE]).to.equal(type); + expect(body[CONSTANTS.CE_ATTRIBUTES.SOURCE]).to.equal(source); + expect(body[CONSTANTS.CE_ATTRIBUTES.SUBJECT]).to.equal(subject); + expect(body[CONSTANTS.CE_ATTRIBUTES.TIME]).to.equal(fixture.time); + expect(body[CONSTANTS.STRUCTURED_ATTRS_03.SCHEMA_URL]).to.equal(schemaurl); + expect(body[ext1Name]).to.equal(ext1Value); + expect(body[ext2Name]).to.equal(ext2Value); + }); + + it("A CloudEvent can be converted from a binary Message", () => { + const message = HTTP.binary(fixture); + const event = HTTP.toEvent(message); + expect(event).to.deep.equal(fixture); + }); + + it("A CloudEvent can be converted from a structured Message", () => { + const message = HTTP.structured(fixture); + const event = HTTP.toEvent(message); + expect(event).to.deep.equal(fixture); + }); + + it("Supports Base-64 encoded data in structured messages", () => { + const event = fixture.cloneWith({ data: dataBinary, datacontentencoding }); + expect(event.data_base64).to.equal(data_base64); + const message = HTTP.structured(event); + const eventDeserialized = HTTP.toEvent(message); + expect(eventDeserialized.data).to.deep.equal({ foo: "bar" }); + }); + + it("Supports Base-64 encoded data in binary messages", () => { + const event = fixture.cloneWith({ data: dataBinary, datacontentencoding }); + expect(event.data_base64).to.equal(data_base64); + const message = HTTP.binary(event); + const eventDeserialized = HTTP.toEvent(message); + expect(eventDeserialized.data).to.deep.equal({ foo: "bar" }); + }); + }); +}); diff --git a/test/integration/receiver_binary_03_tests.ts b/test/integration/receiver_binary_03_tests.ts deleted file mode 100644 index dfdfd736..00000000 --- a/test/integration/receiver_binary_03_tests.ts +++ /dev/null @@ -1,443 +0,0 @@ -import "mocha"; -import { expect } from "chai"; - -import { CloudEvent, ValidationError, Version } from "../../src"; -import { BinaryHTTPReceiver } from "../../src/transport/http/binary_receiver"; -import CONSTANTS from "../../src/constants"; -import { asBase64 } from "../../src/event/validation"; - -const receiver = new BinaryHTTPReceiver(Version.V03); - -describe("HTTP Transport Binding Binary Receiver for CloudEvents v0.3", () => { - describe("Check", () => { - it("Throw error when attributes arg is null or undefined", () => { - // setup - const payload = {}; - const attributes = undefined; - - expect(receiver.parse.bind(receiver, payload, (attributes as unknown) as string)).to.throw( - ValidationError, - "headers is null or undefined", - ); - }); - - it("Throw error when payload is not an object or string", () => { - // setup - const payload = 1.2; - const attributes = {}; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw( - ValidationError, - "payload must be an object or a string", - ); - }); - - it("Throw error when headers has no 'ce-type'", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw( - ValidationError, - "header 'ce-type' not found", - ); - }); - - it("Throw error when headers has no 'ce-specversion'", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw( - ValidationError, - "header 'ce-specversion' not found", - ); - }); - - it("Throw error when headers has no 'ce-source'", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw( - ValidationError, - "header 'ce-source' not found", - ); - }); - - it("Throw error when headers has no 'ce-id'", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw(ValidationError, "header 'ce-id' not found"); - }); - - it("Throw error when spec is not 0.3", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: "0.2", - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw(ValidationError, "invalid spec version"); - }); - - it("Throw error when the content-type is invalid", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "text/html", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw(ValidationError, "invalid content type"); - }); - - it("No error when all required headers are in place", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.not.throw(); - }); - - it("No error when content-type is unspecified", () => { - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.not.throw(); - }); - - it("Succeeds when content-type is application/json and datacontentencoding is base64", () => { - const expected = { - whose: "ours", - }; - const bindata = Uint32Array.from(JSON.stringify(expected) as string, (c) => c.codePointAt(0) as number); - const payload = asBase64(bindata); - - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "test", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "/test-source", - [CONSTANTS.CE_HEADERS.ID]: "123456", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - [CONSTANTS.BINARY_HEADERS_03.CONTENT_ENCODING]: "base64", - }; - const event = receiver.parse(payload, attributes); - expect(event.data).to.deep.equal(expected); - }); - }); - - describe("Parse", () => { - it("CloudEvent contains 'type'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.type).to.equal("type"); - }); - - it("CloudEvent contains 'specversion'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.specversion).to.equal(Version.V03); - }); - - it("CloudEvent contains 'source'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.source).to.equal("/source"); - }); - - it("CloudEvent contains 'id'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.id).to.equal("id"); - }); - - it("CloudEvent contains 'time'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.time).to.equal("2019-06-16T11:42:00.000Z"); - }); - - it("CloudEvent contains 'schemaurl'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.schemaurl).to.equal("http://schema.registry/v1"); - }); - - it("CloudEvent contains 'datacontenttype' (application/json)", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.datacontenttype).to.equal("application/json"); - }); - - it("CloudEvent contains 'datacontenttype' (application/octet-stream)", () => { - // setup - const payload = "The payload is binary data"; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/octet-stream", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.datacontenttype).to.equal("application/octet-stream"); - }); - - it("CloudEvent contains 'data' (application/json)", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.data).to.deep.equal(payload); - }); - - it("CloudEvent contains 'data' (application/octet-stream)", () => { - // setup - const payload = "The payload is binary data"; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/octet-stream", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.data).to.deep.equal(payload); - }); - - it("No error when all attributes are in place", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual).to.be.an.instanceof(CloudEvent); - }); - - it("Should accept 'extension1'", () => { - // setup - const extension1 = "mycuston-ext1"; - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V03, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_03.SCHEMA_URL]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - [`${[CONSTANTS.EXTENSIONS_PREFIX]}extension1`]: extension1, - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.extension1).to.equal(extension1); - }); - }); -}); diff --git a/test/integration/receiver_binary_1_tests.ts b/test/integration/receiver_binary_1_tests.ts deleted file mode 100644 index 368c55ed..00000000 --- a/test/integration/receiver_binary_1_tests.ts +++ /dev/null @@ -1,448 +0,0 @@ -import "mocha"; -import { expect } from "chai"; - -import { CloudEvent, ValidationError, Version } from "../../src"; -import { asBase64 } from "../../src/event/validation"; -import { BinaryHTTPReceiver } from "../../src/transport/http/binary_receiver"; -import CONSTANTS from "../../src/constants"; - -const receiver = new BinaryHTTPReceiver(Version.V1); - -describe("HTTP Transport Binding Binary Receiver for CloudEvents v1.0", () => { - describe("Check", () => { - it("Throw error when attributes arg is null or undefined", () => { - // setup - const payload = {}; - const attributes = undefined; - - expect(receiver.parse.bind(receiver, payload, (attributes as unknown) as string)).to.throw( - ValidationError, - "headers is null or undefined", - ); - }); - - it("Throw error when payload is not an object or string", () => { - // setup - const payload = 1.2; - const attributes = {}; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw( - ValidationError, - "payload must be an object or a string", - ); - }); - - it("Throw error when headers has no 'ce-type'", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw( - ValidationError, - "header 'ce-type' not found", - ); - }); - - it("Throw error when headers has no 'ce-specversion'", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw( - ValidationError, - "header 'ce-specversion' not found", - ); - }); - - it("Throw error when headers has no 'ce-source'", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw( - ValidationError, - "header 'ce-source' not found", - ); - }); - - it("Throw error when headers has no 'ce-id'", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw(ValidationError, "header 'ce-id' not found"); - }); - - it("Throw error when spec is not 1.0", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: "0.2", - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw(ValidationError, "invalid spec version"); - }); - - it("Throw error when the content-type is invalid", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "text/html", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw(ValidationError, "invalid content type"); - }); - - it("No error when content-type is unspecified", () => { - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.not.throw(); - }); - - it("No error when all required headers are in place", () => { - // setup - const payload = {}; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.not.throw(); - }); - }); - - describe("Parse", () => { - it("CloudEvent contains 'type'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.type).to.equal("type"); - }); - - it("CloudEvent contains 'specversion'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.specversion).to.equal(Version.V1); - }); - - it("CloudEvent contains 'source'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.source).to.equal("/source"); - }); - - it("CloudEvent contains 'id'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.id).to.equal("id"); - }); - - it("CloudEvent contains 'time'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.time).to.equal("2019-06-16T11:42:00.000Z"); - }); - - it("CloudEvent contains 'dataschema'", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.dataschema).to.equal("http://schema.registry/v1"); - }); - - it("CloudEvent contains 'contenttype' (application/json)", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.datacontenttype).to.equal("application/json"); - }); - - it("CloudEvent contains 'contenttype' (application/octet-stream)", () => { - // setup - const payload = "The payload is binary data"; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/octet-stream", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.datacontenttype).to.equal("application/octet-stream"); - }); - - it("CloudEvent contains 'data' (application/json)", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.data).to.deep.equal(payload); - }); - - it("CloudEvent contains 'data' (application/octet-stream)", () => { - // setup - const payload = "The payload is binary data"; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/octet-stream", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.data).to.deep.equal(payload); - }); - - it("The content of 'data' is base64 for binary", () => { - // setup - const expected = { - data: "dataString", - }; - const bindata = Uint32Array.from(JSON.stringify(expected) as string, (c) => c.codePointAt(0) as number); - const payload = asBase64(bindata); - - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "/source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.data).to.deep.equal(expected); - }); - - it("No error when all attributes are in place", () => { - // setup - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual).to.be.an.instanceof(CloudEvent); - }); - - it("Should accept 'extension1'", () => { - // setup - const extension1 = "mycustom-ext1"; - const payload = { - data: "dataString", - }; - const attributes = { - [CONSTANTS.CE_HEADERS.TYPE]: "type", - [CONSTANTS.CE_HEADERS.SPEC_VERSION]: Version.V1, - [CONSTANTS.CE_HEADERS.SOURCE]: "source", - [CONSTANTS.CE_HEADERS.ID]: "id", - [CONSTANTS.CE_HEADERS.TIME]: "2019-06-16T11:42:00Z", - [CONSTANTS.BINARY_HEADERS_1.DATA_SCHEMA]: "http://schema.registry/v1", - [CONSTANTS.HEADER_CONTENT_TYPE]: "application/json", - [`${[CONSTANTS.EXTENSIONS_PREFIX]}extension1`]: extension1, - }; - - // act - const actual = receiver.parse(payload, attributes); - - // assert - expect(actual.extension1).to.equal(extension1); - }); - }); -}); diff --git a/test/integration/receiver_structured_0_3_test.ts b/test/integration/receiver_structured_0_3_test.ts deleted file mode 100644 index d47220bf..00000000 --- a/test/integration/receiver_structured_0_3_test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import "mocha"; -import { expect } from "chai"; - -import { CloudEvent, ValidationError, Version } from "../../src"; -import { StructuredHTTPReceiver } from "../../src/transport/http/structured_receiver"; -import { asBase64 } from "../../src/event/validation"; -import CONSTANTS from "../../src/constants"; - -const receiver = new StructuredHTTPReceiver(Version.V03); -const type = "com.github.pull.create"; -const source = "urn:event:from:myapi/resourse/123"; -const time = new Date(); -const schemaurl = "http://cloudevents.io/schema.json"; - -const ceContentType = "application/json"; - -const data = { - foo: "bar", -}; - -describe("HTTP Transport Binding Structured Receiver CloudEvents v0.3", () => { - describe("Check", () => { - it("Throw error when payload arg is null or undefined", () => { - // setup - const payload = null; - const attributes = {}; - - // act and assert - expect(receiver.parse.bind(receiver, (payload as unknown) as string, attributes)).to.throw( - ValidationError, - "payload is null or undefined", - ); - }); - - it("Throw error when attributes arg is null or undefined", () => { - // setup - const payload = {}; - const attributes = null; - - expect(receiver.parse.bind(receiver, payload, (attributes as unknown) as string)).to.throw( - ValidationError, - "headers is null or undefined", - ); - }); - - it("Throw error when payload is not an object or string", () => { - // setup - const payload = 1.0; - const attributes = {}; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw( - ValidationError, - "payload must be an object or a string", - ); - }); - - it("Throw error when the content-type is invalid", () => { - // setup - const payload = {}; - const attributes = { - "Content-Type": "text/html", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw(ValidationError, "invalid content type"); - }); - - it("Throw error data content encoding is base64, but 'data' is not", () => { - // setup - const event = { - specversion: Version.V03, - type, - source, - time, - datacontenttype: "text/plain", - datacontentencoding: "base64", - schemaurl, - data: "No base 64 value", - }; - - const attributes = { - "Content-Type": "application/cloudevents+json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, event, attributes)).to.throw(ValidationError, "invalid payload"); - }); - - it("Succeeds when content-type is application/cloudevents+json and datacontentencoding is base64", () => { - const expected = { - whose: "ours", - }; - const bindata = Uint32Array.from(JSON.stringify(expected) as string, (c) => c.codePointAt(0) as number); - const payload = { - data: asBase64(bindata), - specversion: Version.V03, - source, - type, - datacontentencoding: CONSTANTS.ENCODING_BASE64, - }; - const attributes = { - "Content-Type": "application/cloudevents+json", - }; - - const event = receiver.parse(payload, attributes); - expect(event.data).to.deep.equal(expected); - }); - - it("No error when all required stuff are in place", () => { - // setup - const payload = { - specversion: Version.V03, - source, - type, - }; - const attributes = { - "Content-Type": "application/cloudevents+json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.not.throw(); - }); - }); - - describe("Parse", () => { - it("Throw error when the event does not follow the spec", () => { - // setup - const payload = {}; - const attributes = { - "Content-Type": "application/cloudevents+json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw(); - }); - }); - - describe("Parse", () => { - it("Throw error when the event does not follow the spec", () => { - const payload = { - type, - source, - time, - schemaurl, - data, - }; - - const headers = { - "Content-Type": "application/cloudevents+xml", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, headers)).to.throw(ValidationError, "invalid content type"); - }); - - it("Should accept event that follows the spec", () => { - // setup - const id = "id-x0dk"; - const payload = { - specversion: Version.V03, - id, - type, - source, - time, - schemaurl, - datacontenttype: ceContentType, - data, - }; - const headers = { - "content-type": "application/cloudevents+json", - }; - - // act - const actual = receiver.parse(payload, headers); - - // assert - expect(actual).to.be.an.instanceof(CloudEvent); - expect(actual.id).to.equal(id); - }); - - it("Should accept 'extension1'", () => { - // setup - const extension1 = "mycuston-ext1"; - const payload = { - specversion: Version.V03, - type, - source, - time, - schemaurl, - data, - datacontenttype: ceContentType, - extension1: extension1, - }; - const headers = { - "content-type": "application/cloudevents+json", - }; - - // act - const actual = receiver.parse(payload, headers); - - // assert - expect(actual.extension1).to.equal(extension1); - }); - - it("Should parse 'data' stringfied json to json object", () => { - const payload = { - specversion: Version.V03, - type, - source, - time, - schemaurl, - datacontenttype: ceContentType, - data: JSON.stringify(data), - }; - const headers = { - "content-type": "application/cloudevents+json", - }; - - // act - const actual = receiver.parse(payload, headers); - - // assert - expect(actual.data).to.deep.equal(data); - }); - }); -}); diff --git a/test/integration/receiver_structured_1_test.ts b/test/integration/receiver_structured_1_test.ts deleted file mode 100644 index 250dab9c..00000000 --- a/test/integration/receiver_structured_1_test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import "mocha"; -import { expect } from "chai"; - -import { CloudEvent, ValidationError, Version } from "../../src"; -import { asBase64 } from "../../src/event/validation"; -import { StructuredHTTPReceiver } from "../../src/transport/http/structured_receiver"; - -const receiver = new StructuredHTTPReceiver(Version.V1); -const type = "com.github.pull.create"; -const source = "urn:event:from:myapi/resourse/123"; -const time = new Date(); -const dataschema = "http://cloudevents.io/schema.json"; - -const data = { - foo: "bar", -}; - -describe("HTTP Transport Binding Structured Receiver for CloudEvents v1.0", () => { - describe("Check", () => { - it("Throw error when payload arg is null or undefined", () => { - // setup - const payload = null; - const attributes = {}; - - // act and assert - expect(receiver.parse.bind(receiver, (payload as unknown) as string, attributes)).to.throw( - ValidationError, - "payload is null or undefined", - ); - }); - - it("Throw error when attributes arg is null or undefined", () => { - // setup - const payload = {}; - const attributes = null; - - expect(receiver.parse.bind(receiver, payload, (attributes as unknown) as string)).to.throw( - ValidationError, - "headers is null or undefined", - ); - }); - - it("Throw error when payload is not an object or string", () => { - // setup - const payload = 1.0; - const attributes = {}; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw( - ValidationError, - "payload must be an object or a string", - ); - }); - - it("Throw error when the content-type is invalid", () => { - // setup - const payload = {}; - const attributes = { - "Content-Type": "text/html", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.throw(ValidationError, "invalid content type"); - }); - - it("No error when all required stuff are in place", () => { - // setup - const payload = { - source, - type, - }; - const attributes = { - "Content-Type": "application/cloudevents+json", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, attributes)).to.not.throw(); - }); - }); - - describe("Parse", () => { - it("Throw error when the event does not follow the spec", () => { - // setup - const payload = { - type, - source, - time, - data, - }; - - const headers = { - "Content-Type": "application/cloudevents+xml", - }; - - // act and assert - expect(receiver.parse.bind(receiver, payload, headers)).to.throw(ValidationError, "invalid content type"); - }); - - it("Should accept event that follows the spec", () => { - // setup - const id = "id-x0dk"; - const payload = { - id, - type, - source, - time, - data, - dataschema, - }; - const headers = { - "content-type": "application/cloudevents+json", - }; - - // act - const actual = receiver.parse(payload, headers); - - // assert - expect(actual).to.be.an.instanceof(CloudEvent); - expect(actual.id).to.equal(id); - }); - - it("Should accept 'extension1'", () => { - // setup - const extension1 = "mycustomext1"; - const event = { - type, - source, - time, - data, - dataschema, - extension1, - }; - - const headers = { - "content-type": "application/cloudevents+json", - }; - - // act - const actual = receiver.parse(event, headers); - expect(actual.extension1).to.equal(extension1); - }); - - it("Should parse 'data' stringified json to json object", () => { - // setup - const payload = { - type, - source, - time, - dataschema, - data: data, - }; - - const headers = { - "content-type": "application/cloudevents+json", - }; - - // act - const actual = receiver.parse(payload, headers); - - // assert - expect(actual.data).to.deep.equal(data); - }); - - it("Should maps 'data_base64' to 'data' attribute", () => { - const bindata = Uint32Array.from(JSON.stringify(data), (c) => c.codePointAt(0) as number); - const expected = asBase64(bindata); - const payload = { - type, - source, - data: bindata, - }; - - const headers = { - "content-type": "application/cloudevents+json", - }; - - // act - const actual = receiver.parse(payload, headers); - expect(actual.data_base64).to.equal(expected); - }); - }); -}); diff --git a/test/integration/sdk_test.ts b/test/integration/sdk_test.ts index a949ed0a..d0aa3d1f 100644 --- a/test/integration/sdk_test.ts +++ b/test/integration/sdk_test.ts @@ -1,6 +1,6 @@ import "mocha"; import { expect } from "chai"; -import { CloudEvent, Receiver, Emitter, Version } from "../../src"; +import { CloudEvent, Emitter, Version } from "../../src"; const fixture = { type: "org.cloudevents.test", @@ -13,11 +13,6 @@ describe("The SDK Requirements", () => { expect(event instanceof CloudEvent).to.equal(true); }); - it("should expose a Receiver type", () => { - const receiver = new Receiver(); - expect(receiver instanceof Receiver).to.equal(true); - }); - it("should expose an Emitter type", () => { const emitter = new Emitter({ url: "http://example.com", From ae21dba76e425ff6e2727c0023fba253bf8c28e1 Mon Sep 17 00:00:00 2001 From: Lance Ball Date: Tue, 8 Sep 2020 15:32:16 -0400 Subject: [PATCH 2/4] 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 --- src/event/cloudevent.ts | 20 +++++++++---- src/event/validation.ts | 13 +++++++- src/message/http/headers.ts | 36 ++++------------------ src/message/http/index.ts | 15 +++------- test/integration/cloud_event_test.ts | 24 ++++++++++++--- test/integration/message_test.ts | 45 +++++++++++++++++++++++++--- 6 files changed, 98 insertions(+), 55 deletions(-) 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, From 719c2f01e4b178ed0d610197b65ab21d36770918 Mon Sep 17 00:00:00 2001 From: Lucas Holmquist Date: Thu, 3 Sep 2020 14:52:50 -0400 Subject: [PATCH 3/4] fix: upgrade cloudevents from 3.0.1 to 3.1.0 (#335) Snyk has created this PR to upgrade cloudevents from 3.0.1 to 3.1.0. See this package in npm: https://www.npmjs.com/package/cloudevents See this project in Snyk: https://app.snyk.io/org/lance/project/cb2960b0-db0c-4e77-9ab2-e78efded812e?utm_source=github&utm_medium=upgrade-pr Co-authored-by: snyk-bot Signed-off-by: Lucas Holmquist --- examples/typescript-ex/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/typescript-ex/package.json b/examples/typescript-ex/package.json index 111195e7..7a2c9c48 100644 --- a/examples/typescript-ex/package.json +++ b/examples/typescript-ex/package.json @@ -28,6 +28,6 @@ "typescript": "~3.9.5" }, "dependencies": { - "cloudevents": "~3.0.1" + "cloudevents": "~3.1.0" } } From d8df5efa94480009c7f71046ecf67b20ee96dc14 Mon Sep 17 00:00:00 2001 From: Philip Hayes Date: Wed, 2 Sep 2020 17:09:53 -0400 Subject: [PATCH 4/4] chore(example): Replaced body parser with express JSON parser (#334) Signed-off-by: Philip Hayes Co-authored-by: Philip Hayes --- examples/express-ex/index.js | 16 ++-------------- examples/express-ex/package.json | 1 + 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/examples/express-ex/index.js b/examples/express-ex/index.js index cfe68e95..cc8e2349 100644 --- a/examples/express-ex/index.js +++ b/examples/express-ex/index.js @@ -3,20 +3,8 @@ const express = require("express"); const { Receiver } = require("cloudevents"); const app = express(); - -app.use((req, res, next) => { - let data = ""; - - req.setEncoding("utf8"); - req.on("data", function (chunk) { - data += chunk; - }); - - req.on("end", function () { - req.body = data; - next(); - }); -}); +const bodyParser = require('body-parser') +app.use(bodyParser.json()) app.post("/", (req, res) => { console.log("HEADERS", req.headers); diff --git a/examples/express-ex/package.json b/examples/express-ex/package.json index 756d8f7b..74fa5232 100644 --- a/examples/express-ex/package.json +++ b/examples/express-ex/package.json @@ -14,6 +14,7 @@ "author": "fabiojose@gmail.com", "license": "Apache-2.0", "dependencies": { + "body-parser": "^1.19.0", "cloudevents": "^3.1.0", "express": "^4.17.1" }