From 22ad9bae1123ea58bcaddf1211a0b86eb7795b11 Mon Sep 17 00:00:00 2001 From: eisen0419 Date: Sat, 14 Nov 2026 21:25:42 +0800 Subject: [PATCH] fix: align HTTP binding with the CloudEvents spec Normalize Content-Type media types, keep falsy JSON data, and percent-encode ce- header values per the HTTP protocol binding. Signed-off-by: eisen0419 --- src/message/http/headers.ts | 160 +++++++++- src/message/http/index.ts | 47 ++- src/parsers.ts | 113 ++++++- test/integration/emitter_factory_test.ts | 13 +- test/integration/http_binding_interop_test.ts | 287 ++++++++++++++++++ test/integration/message_test.ts | 7 +- test/integration/parser_test.ts | 13 + 7 files changed, 601 insertions(+), 39 deletions(-) create mode 100644 test/integration/http_binding_interop_test.ts diff --git a/src/message/http/headers.ts b/src/message/http/headers.ts index 15fb15e4..98b54eff 100644 --- a/src/message/http/headers.ts +++ b/src/message/http/headers.ts @@ -8,7 +8,7 @@ import { CloudEventV1 } from "../.."; import { Headers } from "../"; import { V1 } from "../../event/cloudevent"; import CONSTANTS from "../../constants"; -import { asBase64, isBinary, isDate, TypeArray } from "../../event/validation"; +import { asBase64, isBinary, isDate, TypeArray, ValidationError } from "../../event/validation"; export const allowedContentTypes = [CONSTANTS.DEFAULT_CONTENT_TYPE, CONSTANTS.MIME_JSON, CONSTANTS.MIME_OCTET_STREAM]; export const requiredHeaders = [ @@ -40,19 +40,171 @@ export function headersFor(event: CloudEventV1): Headers { if (value !== undefined) { const map: MappedParser | undefined = headerMap[property] as MappedParser; if (map) { - headers[map.name] = map.parser.parse(value as string) as string; + headers[map.name] = asCeHeader(map.name, map.parser.parse(value as string)); } else if (property !== CONSTANTS.DATA_ATTRIBUTE && property !== `${CONSTANTS.DATA_ATTRIBUTE}_base64`) { - headers[`${CONSTANTS.EXTENSIONS_PREFIX}${property}`] = stringifyExtensionValue(value); + const name = `${CONSTANTS.EXTENSIONS_PREFIX}${property}`; + headers[name] = asCeHeader(name, stringifyExtensionValue(value)); } } }); // Treat time specially, since it's handled with getters and setters in CloudEvent if (event.time) { - headers[CONSTANTS.CE_HEADERS.TIME] = new Date(event.time).toISOString(); + headers[CONSTANTS.CE_HEADERS.TIME] = asCeHeader( + CONSTANTS.CE_HEADERS.TIME, + new Date(event.time).toISOString(), + ); } return headers; } +/** + * Percent-encode a `ce-` header value per HTTP Protocol Binding §3.1.3.2. + * + * `content-type` is not a `ce-` header (`datacontenttype` maps to it) and is + * left unchanged, including its spaces. Non-strings stay as they are: boolean + * and integer extensions are canonicalized by the HTTP client, matching the + * existing Message shape. + * @param {string} name the header name + * @param {unknown} value the header value + * @return {string} the value to put on the Message + */ +function asCeHeader(name: string, value: unknown): string { + if (typeof value === "string" && name.toLowerCase().startsWith(CONSTANTS.EXTENSIONS_PREFIX)) { + return percentEncode(value); + } + return value as string; +} + +/** + * Encode one Unicode code point the way §3.1.3.2 requires. + * Space, `"`, `%`, and code points outside U+0021–U+007E are UTF-8 percent-encoded. + * Printable ASCII, including `/`, `:`, `+`, `-`, `_`, and `.`, is left as-is. + * @param {string} value the attribute's canonical string + * @return {string} the header value + */ +function percentEncode(value: string): string { + let out = ""; + for (let i = 0; i < value.length;) { + const cp = value.codePointAt(i) as number; + i += cp > 0xFFFF ? 2 : 1; + if (cp === 0x20 || cp === 0x22 || cp === 0x25 || cp < 0x21 || cp > 0x7E) { + const bytes: number[] = []; + utf8Bytes(cp, bytes); + for (let b = 0; b < bytes.length; b++) { + const hex = bytes[b].toString(16).toUpperCase(); + out += `%${hex.length === 1 ? "0" : ""}${hex}`; + } + } else { + out += String.fromCodePoint(cp); + } + } + return out; +} + +function utf8Bytes(codePoint: number, into: number[]): void { + if (codePoint <= 0x7F) { + into.push(codePoint); + } else if (codePoint <= 0x7FF) { + into.push(0xC0 | (codePoint >> 6), 0x80 | (codePoint & 0x3F)); + } else if (codePoint <= 0xFFFF) { + into.push(0xE0 | (codePoint >> 12), 0x80 | ((codePoint >> 6) & 0x3F), 0x80 | (codePoint & 0x3F)); + } else { + into.push( + 0xF0 | (codePoint >> 18), + 0x80 | ((codePoint >> 12) & 0x3F), + 0x80 | ((codePoint >> 6) & 0x3F), + 0x80 | (codePoint & 0x3F), + ); + } +} + +function isHex(ch: string): boolean { + return (ch >= "0" && ch <= "9") || (ch >= "A" && ch <= "F") || (ch >= "a" && ch <= "f"); +} + +function trimOws(value: string): string { + let start = 0; + let end = value.length; + while (start < end && (value.charAt(start) === " " || value.charAt(start) === "\t")) { + start++; + } + while (end > start && (value.charAt(end - 1) === " " || value.charAt(end - 1) === "\t")) { + end--; + } + return value.slice(start, end); +} + +/** + * Unwrap an RFC 7230 quoted-string, including backslash escapes. + * A value that is not a single quoted-string is returned unchanged. + * @param {string} value a header value with OWS already removed + * @return {string} the unescaped value + */ +function unquoteHeaderValue(value: string): string { + if (value.length < 2 || value.charAt(0) !== "\"" || value.charAt(value.length - 1) !== "\"") { + return value; + } + let out = ""; + let escaped = false; + for (let i = 1; i < value.length - 1; i++) { + const ch = value.charAt(i); + if (escaped) { + out += ch; + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === "\"") { + return value; + } else { + out += ch; + } + } + return escaped ? value : out; +} + +/** + * One round of UTF-8 percent-decoding. A `%` not followed by two hex digits + * stays literal. Ill-formed UTF-8, including overlong sequences, is rejected. + * @param {string} value the unquoted header value + * @return {string} the decoded attribute value + */ +function percentDecode(value: string): string { + const bytes: number[] = []; + for (let i = 0; i < value.length;) { + const cp = value.codePointAt(i) as number; + if (cp === 0x25 && i + 2 < value.length && isHex(value.charAt(i + 1)) && isHex(value.charAt(i + 2))) { + bytes.push(parseInt(value.slice(i + 1, i + 3), 16)); + i += 3; + continue; + } + utf8Bytes(cp, bytes); + i += cp > 0xFFFF ? 2 : 1; + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes)); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new ValidationError(`invalid UTF-8 in CloudEvents HTTP header: ${detail}`); + } +} + +function decodeOneCeHeader(value: string): string { + // §3.1.3.2: trim OWS, unescape a quoted-string, then percent-decode once. + return percentDecode(unquoteHeaderValue(trimOws(value))); +} + +/** + * Decode a received `ce-` header value per HTTP Protocol Binding §3.1.3.2. + * @param {string|string[]} value the header value + * @return {string|string[]} the CloudEvents attribute value + */ +export function decodeCeHeader(value: string | string[]): string | string[] { + if (Array.isArray(value)) { + return value.map(decodeOneCeHeader); + } + return decodeOneCeHeader(value); +} + /** * True for objects created by a literal, `new Object()` or `Object.create(null)`. * The check looks at the prototype, so it is not fooled by a shadowed `constructor` diff --git a/src/message/http/index.ts b/src/message/http/index.ts index 71a6642f..33a62867 100644 --- a/src/message/http/index.ts +++ b/src/message/http/index.ts @@ -9,6 +9,7 @@ import { CloudEvent, CloudEventV1, CONSTANTS, Mode, V1, V03 } from "../.."; import { Message, Headers, Binding } from ".."; import { + decodeCeHeader, headersFor, sanitize, v03binaryParsers, @@ -17,7 +18,7 @@ import { v1structuredParsers, } from "./headers"; import { base64AsBinary, isStringOrObjectOrThrow, ValidationError } from "../../event/validation"; -import { JSONParser, MappedParser, Parser, parserByContentType } from "../../parsers"; +import { contentTypeEssence, JSONParser, MappedParser, Parser, parserForContentType } from "../../parsers"; /** * Serialize a CloudEvent for HTTP transport in binary mode @@ -102,6 +103,13 @@ function deserialize(message: Message): CloudEvent | CloudEvent[] { } } +function headerAsString(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + /** * Determines the HTTP transport mode (binary or structured) based * on the incoming HTTP headers. @@ -109,11 +117,15 @@ function deserialize(message: Message): CloudEvent | CloudEvent[] { * @returns {Mode} the transport mode */ function getMode(headers: Headers): Mode { - const contentType = headers[CONSTANTS.HEADER_CONTENT_TYPE]; + const contentType = headerAsString(headers[CONSTANTS.HEADER_CONTENT_TYPE]); if (contentType) { - if (contentType.startsWith(CONSTANTS.MIME_CE_BATCH)) { + // Match the media type, not the raw header: parameters, whitespace, and case + // do not change the mode. Batch is a prefix of the structured type (`application/cloudevents`), + // so `application/cloudevents-batch` has to be tested first. + const essence = contentTypeEssence(contentType); + if (essence.startsWith("application/cloudevents-batch")) { return Mode.BATCH; - } else if (contentType.startsWith(CONSTANTS.MIME_CE)) { + } else if (essence.startsWith(CONSTANTS.MIME_CE)) { return Mode.STRUCTURED; } } @@ -174,7 +186,10 @@ function parseBinary(message: Message, version: string): CloudEvent { for (const header in parserMap) { if (sanitizedHeaders[header]) { const mappedParser: MappedParser = parserMap[header]; - eventObj[mappedParser.name] = mappedParser.parser.parse(sanitizedHeaders[header]); + const raw = sanitizedHeaders[header] as string | string[]; + // `content-type` carries datacontenttype and must not be percent-decoded. + const decoded = header.startsWith(CONSTANTS.EXTENSIONS_PREFIX) ? decodeCeHeader(raw) : raw; + eventObj[mappedParser.name] = mappedParser.parser.parse(decoded as string); delete sanitizedHeaders[header]; delete headers[header]; } @@ -185,11 +200,13 @@ function parseBinary(message: Message, version: string): CloudEvent { for (const header in headers) { const lowerCaseHeader = header.toLowerCase(); if (lowerCaseHeader.startsWith(CONSTANTS.EXTENSIONS_PREFIX) && !parserMap[lowerCaseHeader]) { - eventObj[header.substring(CONSTANTS.EXTENSIONS_PREFIX.length)] = headers[header]; + const raw = headers[header]; + eventObj[header.substring(CONSTANTS.EXTENSIONS_PREFIX.length)] = + typeof raw === "string" || Array.isArray(raw) ? decodeCeHeader(raw) : raw; } } - const parser = parserByContentType[eventObj.datacontenttype as string]; + const parser = parserForContentType(headerAsString(eventObj.datacontenttype as string | string[] | undefined)); if (parser && body) { body = parser.parse(body as string); } @@ -223,8 +240,8 @@ function parseStructured(message: Message, version: string): CloudEvent { // 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(); + const contentType = headerAsString(sanitizedHeaders[CONSTANTS.HEADER_CONTENT_TYPE]); + const parser: Parser | undefined = contentType ? parserForContentType(contentType) : new JSONParser(); if (!parser) throw new ValidationError(`invalid content type ${sanitizedHeaders[CONSTANTS.HEADER_CONTENT_TYPE]}`); const incoming = { ...(parser.parse(payload as string) as Record) }; @@ -232,10 +249,14 @@ function parseStructured(message: Message, version: string): CloudEvent { const parserMap: Record = version === V03 ? v03structuredParsers : v1structuredParsers; for (const key in parserMap) { - const property = incoming[key]; - if (property) { - const mappedParser: MappedParser = parserMap[key]; - eventObj[mappedParser.name] = mappedParser.parser.parse(property as string); + if (Object.prototype.hasOwnProperty.call(incoming, key)) { + const property = incoming[key]; + // Keep falsy JSON values. `data: 0`, `false`, `""`, and `null` are present + // and must survive a structured round trip; only a missing attribute is skipped. + if (typeof property !== "undefined") { + const mappedParser: MappedParser = parserMap[key]; + eventObj[mappedParser.name] = mappedParser.parser.parse(property as string); + } } delete incoming[key]; } diff --git a/src/parsers.ts b/src/parsers.ts index 7a050672..a61663d0 100644 --- a/src/parsers.ts +++ b/src/parsers.ts @@ -19,25 +19,28 @@ export class JSONParser implements Parser { } /** - * Parses the payload with an optional decorator + * Parses the payload with an optional decorator. + * + * Valid JSON text is parsed as JSON, including primitives (`0`, `false`, + * `null`, numbers, and quoted strings). A raw string that is not JSON text + * and does not look like a JSON value (for example `I am a string!`) is + * still accepted as a JSON string. * @param {object|string} payload the JSON payload * @return {unknown} the parsed JSON payload. */ parse(payload: Record | string): unknown { - if (typeof payload === "string") { - // This is kind of a hack, but the payload data could be JSON in the form of a single - // string, such as "some data". But without the quotes in the string, JSON.parse blows - // up. We can check for this scenario and add quotes. Not sure if this is ideal. - if (!/^[[|{|"]/.test(payload)) { - payload = `"${payload}"`; - } + let value: unknown = payload; + // Decorator order matches the historical base64 path: quote a non-JSON + // string first, then let the decorator decode, then parse. + if (this.decorator && typeof value === "string" && !JSON_TEXT_START.test(value)) { + value = `"${value}"`; } if (this.decorator) { - payload = this.decorator.parse(payload); + value = this.decorator.parse(value as Record | string); } - isDefinedOrThrow(payload, new ValidationError("null or undefined payload")); - isStringOrObjectOrThrow(payload, new ValidationError("invalid payload type, allowed are: string or object")); + isDefinedOrThrow(value, new ValidationError("null or undefined payload")); + isStringOrObjectOrThrow(value, new ValidationError("invalid payload type, allowed are: string or object")); if (process.env[CONSTANTS.USE_BIG_INT_ENV] === "true") { JSON = JSONbig(({ useNativeBigInt: true })) as JSON; @@ -45,8 +48,21 @@ export class JSONParser implements Parser { JSON = __JSON; } - const parseJSON = (v: Record | string): unknown => (isString(v) ? JSON.parse(v as string) : v); - return parseJSON(payload); + if (!isString(value)) { + return value; + } + + const text = value as string; + try { + return JSON.parse(text); + } catch (err) { + // Legacy callers pass unquoted application strings. Structured JSON that + // fails to parse (`{gg`) still throws. + if (!this.decorator && !JSON_TEXT_START.test(text)) { + return JSON.parse(`"${text}"`); + } + throw err; + } } } @@ -56,6 +72,12 @@ export class PassThroughParser extends Parser { } } +/** + * True when `payload` starts like JSON text that must not be auto-quoted. + * The character class is historical: `[`, `{`, `"`, and a literal `|`. + */ +const JSON_TEXT_START = /^[[{|"]/; + const jsonParser = new JSONParser(); export const parserByContentType: { [key: string]: Parser } = { [CONSTANTS.MIME_JSON]: jsonParser, @@ -97,6 +119,71 @@ export class DateParser extends Parser { } } +/** + * RFC 9110 media-type essence: `type/subtype`, without parameters, lowercased. + * `application/cloudevents+json; charset=UTF-8` becomes `application/cloudevents+json`. + * @param {string} contentType a Content-Type header value + * @return {string} the normalized media type, or an empty string + */ +export function contentTypeEssence(contentType: string): string { + const semi = contentType.indexOf(";"); + const type = semi === -1 ? contentType : contentType.slice(0, semi); + return type.trim().toLowerCase(); +} + +/** + * True for `type/json` and structured-syntax `type/subtype+json` media types. + * @param {string} essence a normalized media type + * @return {boolean} true when the media type is JSON or a +json suffix + */ +function isJsonEssence(essence: string): boolean { + const slash = essence.indexOf("/"); + if (slash <= 0 || slash === essence.length - 1) { + return false; + } + const subtype = essence.slice(slash + 1); + return subtype === "json" || subtype.endsWith("+json"); +} + +/** + * Resolves a parser for an HTTP Content-Type. + * + * Lookup normalizes the media type (case, surrounding whitespace, parameters). + * An entry in {@link parserByContentType} always wins, including a custom + * parser registered for a `+json` type. Unregistered `+json` and `json` + * media types use the JSON parser. + * @param {string} contentType a Content-Type header value + * @return {Parser|undefined} the parser, or undefined when none applies + */ +export function parserForContentType(contentType: string | undefined): Parser | undefined { + if (!contentType) { + return undefined; + } + const direct = parserByContentType[contentType]; + if (direct) { + return direct; + } + const essence = contentTypeEssence(contentType); + if (!essence) { + return undefined; + } + const byEssence = parserByContentType[essence]; + if (byEssence) { + return byEssence; + } + const keys = Object.keys(parserByContentType); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (contentTypeEssence(key) === essence) { + return parserByContentType[key]; + } + } + if (isJsonEssence(essence)) { + return jsonParser; + } + return undefined; +} + export const parserByEncoding: { [key: string]: { [key: string]: Parser } } = { base64: { [CONSTANTS.MIME_CE_JSON]: new JSONParser(new Base64Parser()), diff --git a/test/integration/emitter_factory_test.ts b/test/integration/emitter_factory_test.ts index 03b21832..b534ca37 100644 --- a/test/integration/emitter_factory_test.ts +++ b/test/integration/emitter_factory_test.ts @@ -39,9 +39,9 @@ export const fixture = new CloudEvent({ }); function axiosEmitter(message: Message, options?: Options): Promise { - // axios 1.x coerces every header value to a string. The fixture carries an - // object-valued extension, which the binary binding passes through untouched, - // so serialize it as JSON here to keep it readable on the receiving side. + // axios 1.x coerces every header value to a string. ce- values are already + // strings (JSON extensions are percent-encoded). Anything else is JSON-encoded + // here so axios does not send "[object Object]". const headers: Record = {}; for (const [name, value] of Object.entries(message.headers)) { headers[name] = typeof value === "string" ? value : JSON.stringify(value); @@ -187,10 +187,11 @@ export function assertBinary(response: Record): void { expect(response["ce-source"]).to.equal(source); expect(response[`ce-${ext1Name}`]).to.deep.equal(ext1Value); expect(response[`ce-${ext2Name}`]).to.deep.equal(ext2Value); - // nock hands object-valued headers back untouched; a client that serializes - // headers (axios 1.x) delivers the JSON form instead + // ce- headers percent-encode `"` (§3.1.3.2). Decode once, then parse the JSON + // extension. A transport that already handed back an object is used as-is. const ext3 = response[`ce-${ext3Name}`]; - expect(typeof ext3 === "string" ? JSON.parse(ext3) : ext3).to.deep.equal(ext3Value); + const ext3Text = typeof ext3 === "string" ? decodeURIComponent(ext3) : ext3; + expect(typeof ext3Text === "string" ? JSON.parse(ext3Text) : ext3Text).to.deep.equal(ext3Value); } /** diff --git a/test/integration/http_binding_interop_test.ts b/test/integration/http_binding_interop_test.ts new file mode 100644 index 00000000..c28963f8 --- /dev/null +++ b/test/integration/http_binding_interop_test.ts @@ -0,0 +1,287 @@ +/* + Copyright 2021 The CloudEvents Authors + SPDX-License-Identifier: Apache-2.0 +*/ + +import http from "http"; +import { expect } from "chai"; +import { CloudEvent, CONSTANTS, HTTP, ValidationError, emitterFor, httpTransport } from "../../src"; +import { PassThroughParser, parserByContentType } from "../../src/parsers"; + +const baseHeaders = { + "ce-specversion": "1.0", + "ce-id": "1", + "ce-source": "/s", + "ce-type": "t", +}; + +function structuredBody(data: unknown): string { + return JSON.stringify({ + specversion: "1.0", + id: "1", + source: "/s", + type: "t", + data, + }); +} + +describe("HTTP binding interoperability (#675)", () => { + describe("Content-Type media type matching", () => { + const body = structuredBody({ a: 1 }); + + for (const contentType of [ + "application/cloudevents+json; charset=UTF-8", + "application/cloudevents+json;charset=utf-8", + "APPLICATION/CLOUDEVENTS+JSON", + " application/cloudevents+json ; charset=UTF-8 ", + ]) { + it(`parses structured mode for ${JSON.stringify(contentType)}`, () => { + const event = HTTP.toEvent({ headers: { "content-type": contentType }, body }) as CloudEvent; + expect(event.data).to.deep.equal({ a: 1 }); + }); + } + + for (const contentType of [ + "application/cloudevents-batch+json; charset=UTF-8", + "APPLICATION/CLOUDEVENTS-BATCH+JSON", + "application/cloudevents-batch", + ]) { + it(`parses batch mode for ${JSON.stringify(contentType)}`, () => { + const batch = HTTP.toEvent({ + headers: { "content-type": contentType }, + body: JSON.stringify([ + { specversion: "1.0", id: "1", source: "/s", type: "t", data: { a: 1 } }, + ]), + }) as CloudEvent[]; + expect(batch).to.have.length(1); + expect(batch[0].data).to.deep.equal({ a: 1 }); + }); + } + + for (const contentType of [ + "application/json; charset=UTF-8", + "application/json;charset=utf-8", + "APPLICATION/JSON", + "application/vnd.api+json", + "application/problem+json", + "text/json", + ]) { + it(`parses a binary JSON body for ${contentType}`, () => { + const event = HTTP.toEvent({ + headers: { ...baseHeaders, "content-type": contentType }, + body: "{\"a\":1}", + }) as CloudEvent; + expect(event.data).to.deep.equal({ a: 1 }); + }); + } + + it("prefers a registered parser over the +json fallback", () => { + const custom = new PassThroughParser(); + parserByContentType["application/vnd.api+json"] = custom; + try { + const event = HTTP.toEvent({ + headers: { + ...baseHeaders, + "content-type": "application/vnd.api+json; charset=UTF-8", + }, + body: "{\"a\":1}", + }) as CloudEvent; + expect(event.data).to.equal("{\"a\":1}"); + } finally { + delete parserByContentType["application/vnd.api+json"]; + } + }); + + it("matches a custom parser registered with parameters and mixed case", () => { + const custom = new PassThroughParser(); + parserByContentType["Application/Vnd.Custom+JSON; charset=utf-8"] = custom; + try { + const event = HTTP.toEvent({ + headers: { ...baseHeaders, "content-type": "application/vnd.custom+json" }, + body: "raw", + }) as CloudEvent; + expect(event.data).to.equal("raw"); + } finally { + delete parserByContentType["Application/Vnd.Custom+JSON; charset=utf-8"]; + } + }); + }); + + describe("falsy data and JSON primitives", () => { + for (const value of [0, false, "", null, true, 42]) { + it(`round-trips structured data ${JSON.stringify(value)}`, () => { + const event = new CloudEvent({ source: "/s", type: "t", data: value }); + const message = HTTP.structured(event); + expect(JSON.parse(message.body as string).data).to.equal(value); + const received = HTTP.toEvent(message) as CloudEvent; + expect(received.data).to.equal(value); + }); + } + + for (const value of [0, false, "", null]) { + it(`round-trips binary data ${JSON.stringify(value)}`, () => { + const event = new CloudEvent({ source: "/s", type: "t", data: value }); + const received = HTTP.toEvent(HTTP.binary(event)) as CloudEvent; + expect(received.data).to.equal(value); + }); + } + + const primitives: Array<[string, unknown]> = [ + ["0", 0], + ["false", false], + ["true", true], + ["null", null], + ["-42", -42], + ["3.14", 3.14], + ["\"hello\"", "hello"], + ["[1,2]", [1, 2]], + ["{\"a\":1}", { a: 1 }], + ]; + + for (const [raw, expected] of primitives) { + it(`parses binary application/json body ${raw}`, () => { + const event = HTTP.toEvent({ + headers: { ...baseHeaders, "content-type": "application/json" }, + body: raw, + }) as CloudEvent; + expect(event.data).to.deep.equal(expected); + }); + } + }); + + describe("ce- header percent-encoding", () => { + it("encodes space, percent, quote, and non-ASCII on send", () => { + const message = HTTP.binary(new CloudEvent({ + source: "/s", + type: "t", + subject: "a b%c\"€", + data: "x", + })); + expect(message.headers["ce-subject"]).to.equal("a%20b%25c%22%E2%82%AC"); + }); + + it("leaves printable ASCII punctuation unencoded", () => { + const subject = "a/b:c+d-e_f.g"; + const message = HTTP.binary(new CloudEvent({ source: "/s", type: "t", subject, data: "x" })); + expect(message.headers["ce-subject"]).to.equal(subject); + }); + + it("does not percent-encode content-type", () => { + const message = HTTP.binary(new CloudEvent({ + source: "/s", + type: "t", + subject: "Euro €", + datacontenttype: "application/json; charset=utf-8", + data: { a: 1 }, + })); + expect(message.headers[CONSTANTS.HEADER_CONTENT_TYPE]).to.equal("application/json; charset=utf-8"); + expect(message.headers["ce-datacontenttype"]).to.equal(undefined); + expect(message.headers["ce-subject"]).to.equal("Euro%20%E2%82%AC"); + }); + + it("keeps boolean and integer extension values unstringified", () => { + const message = HTTP.binary(new CloudEvent({ + source: "/s", + type: "t", + extboolean: false, + extint: 0, + })); + expect(message.headers["ce-extboolean"]).to.equal(false); + expect(message.headers["ce-extint"]).to.equal(0); + }); + + it("keeps an RFC 3339 extension and URI punctuation unencoded", () => { + const message = HTTP.binary(new CloudEvent({ + source: "urn:event:from:myapi/resource/123", + type: "t", + extts: "2018-04-05T03:56:24+00:00", + })); + expect(message.headers["ce-source"]).to.equal("urn:event:from:myapi/resource/123"); + expect(message.headers["ce-extts"]).to.equal("2018-04-05T03:56:24+00:00"); + }); + + it("decodes a percent-encoded subject, including lowercase hex", () => { + const event = HTTP.toEvent({ + headers: { ...baseHeaders, "ce-subject": "Euro%20%E2%82%AC", "content-type": "text/plain" }, + body: "x", + }) as CloudEvent; + expect(event.subject).to.equal("Euro €"); + + const lower = HTTP.toEvent({ + headers: { ...baseHeaders, "ce-subject": "Euro%20%e2%82%ac" }, + body: "x", + }) as CloudEvent; + expect(lower.subject).to.equal("Euro €"); + }); + + it("round-trips Euro, space, quote, percent, and an emoji", () => { + const subject = "Euro € 😀"; + const message = HTTP.binary(new CloudEvent({ source: "/s", type: "t", subject, data: "x" })); + expect(message.headers["ce-subject"]).to.equal("Euro%20%E2%82%AC%20%F0%9F%98%80"); + const received = HTTP.toEvent(message) as CloudEvent; + expect(received.subject).to.equal(subject); + }); + + it("accepts an unnecessarily encoded byte and decodes percent once", () => { + const event = HTTP.toEvent({ + headers: { ...baseHeaders, "ce-subject": "%41%2520" }, + body: "x", + }) as CloudEvent; + expect(event.subject).to.equal("A%20"); + }); + + it("trims OWS and unwraps a quoted-string before percent-decoding", () => { + const quoted = HTTP.toEvent({ + headers: { ...baseHeaders, "ce-subject": " \"Euro%20%E2%82%AC\"\t" }, + body: "x", + }) as CloudEvent; + expect(quoted.subject).to.equal("Euro €"); + + const escaped = HTTP.toEvent({ + headers: { ...baseHeaders, "ce-subject": "\"say \\\"hi\\\"\"" }, + body: "x", + }) as CloudEvent; + expect(escaped.subject).to.equal("say \"hi\""); + }); + + it("leaves a malformed percent sequence in place", () => { + const event = HTTP.toEvent({ + headers: { ...baseHeaders, "ce-subject": "100%2" }, + body: "x", + }) as CloudEvent; + expect(event.subject).to.equal("100%2"); + }); + + it("rejects overlong UTF-8 percent sequences", () => { + expect(() => HTTP.toEvent({ + headers: { ...baseHeaders, "ce-subject": "%C0%A0" }, + body: "x", + })).to.throw(ValidationError, /invalid UTF-8/); + }); + + it("sends a non-ASCII subject through httpTransport", async () => { + let seen: string | string[] | undefined; + const server = http.createServer((req, res) => { + seen = req.headers["ce-subject"]; + res.statusCode = 204; + res.end(); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + try { + const addr = server.address(); + if (!addr || typeof addr === "string") { + throw new Error("expected a TCP port"); + } + const emit = emitterFor(httpTransport(`http://127.0.0.1:${addr.port}`)); + await emit(new CloudEvent({ source: "/s", type: "t", subject: "Euro €", data: "x" })); + expect(seen).to.equal("Euro%20%E2%82%AC"); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); + }); +}); diff --git a/test/integration/message_test.ts b/test/integration/message_test.ts index 56146d0c..3dacbea7 100644 --- a/test/integration/message_test.ts +++ b/test/integration/message_test.ts @@ -70,7 +70,8 @@ describe("HTTP transport", () => { const evt = new CloudEvent({ source: "test", type: "test", extobject }); const message = HTTP.binary(evt); // HTTP headers can only carry strings; a raw object reaches the wire as "[object Object]" - expect(message.headers["ce-extobject"]).to.equal(JSON.stringify(extobject)); + // `"` in the JSON text is percent-encoded on ce- headers (§3.1.3.2) + expect(message.headers["ce-extobject"]).to.equal(JSON.stringify(extobject).replace(/"/g, "%22")); }); it("Round-trips an object-valued extension through binary mode as a JSON string", () => { @@ -101,8 +102,8 @@ describe("HTTP transport", () => { const extshadowed = { constructor: "shadowed", some: "object" }; const extnullproto = Object.assign(Object.create(null), { some: "object" }); const message = HTTP.binary(new CloudEvent({ source: "test", type: "test", extshadowed, extnullproto })); - expect(message.headers["ce-extshadowed"]).to.equal(JSON.stringify(extshadowed)); - expect(message.headers["ce-extnullproto"]).to.equal(JSON.stringify(extnullproto)); + expect(message.headers["ce-extshadowed"]).to.equal(JSON.stringify(extshadowed).replace(/"/g, "%22")); + expect(message.headers["ce-extnullproto"]).to.equal(JSON.stringify(extnullproto).replace(/"/g, "%22")); }); it("Leaves non-CloudEvents values in binary-mode extensions untouched", () => { diff --git a/test/integration/parser_test.ts b/test/integration/parser_test.ts index 5be19b3e..4eecd945 100644 --- a/test/integration/parser_test.ts +++ b/test/integration/parser_test.ts @@ -67,6 +67,19 @@ describe("JSON Event Format Parser", () => { expect(parser.parse(payload)).to.equal("I am a string!"); }); + it("Parses JSON primitives instead of quoting them", () => { + const parser = new Parser(); + expect(parser.parse("0")).to.equal(0); + expect(parser.parse("false")).to.equal(false); + expect(parser.parse("true")).to.equal(true); + expect(parser.parse("null")).to.equal(null); + expect(parser.parse("-42")).to.equal(-42); + expect(parser.parse("3.14")).to.equal(3.14); + expect(parser.parse("\"hello\"")).to.equal("hello"); + expect(parser.parse("[1,2]")).to.deep.equal([1, 2]); + expect(parser.parse("{\"a\":1}")).to.deep.equal({ a: 1 }); + }); + it("Must accept when the payload is a string well formed as JSON", () => { // setup const payload = "{\"much\" : \"wow\"}";