From a8f544d5c595c680703fa84be99b6c34ea702b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 11:55:21 +0200 Subject: [PATCH 1/2] feat: add JSON event format support Implements the CloudEvents v1.0 JSON event format with toJson() and fromJson(), covering what most transports (HTTP structured mode, Kafka, MQs) actually exchange. - toJson() serializes the event, emitting non-UTF-8 string data as the data_base64 member since it cannot be carried in the data member - fromJson() parses and validates the envelope, decodes data_base64 (rejecting invalid Base64 and the presence of both data and data_base64), and surfaces malformed JSON as InvalidArgumentException Co-Authored-By: Claude Fable 5 --- src/CloudEvents/CloudEvent.php | 75 +++++++++++++++++++ tests/CloudEvents/CloudEventTest.php | 107 +++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index 41bdaff..39b2d6d 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -3,6 +3,7 @@ namespace Utopia\CloudEvents; use InvalidArgumentException; +use JsonException; /** * CloudEvent class representing the CloudEvents v1.0 specification @@ -142,6 +143,80 @@ public function toArray(): array return $array + $this->extensions; } + /** + * Create CloudEvent from its JSON event format representation + * + * Binary payloads carried in the data_base64 member are decoded + * into data. + * + * @see https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md + * + * @param string $json + * @return self + * @throws InvalidArgumentException + */ + public static function fromJson(string $json): self + { + try { + $decoded = \json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new InvalidArgumentException('Invalid CloudEvent JSON: ' . $e->getMessage(), 0, $e); + } + + if (!\is_array($decoded)) { + throw new InvalidArgumentException('CloudEvent JSON must decode to an object'); + } + + if (\array_key_exists('data_base64', $decoded)) { + if (\array_key_exists('data', $decoded)) { + throw new InvalidArgumentException('CloudEvent must not contain both data and data_base64'); + } + + if (!\is_string($decoded['data_base64'])) { + throw new InvalidArgumentException('data_base64 must be a string'); + } + + $binary = \base64_decode($decoded['data_base64'], true); + + if ($binary === false) { + throw new InvalidArgumentException('data_base64 must be valid Base64'); + } + + unset($decoded['data_base64']); + $decoded['data'] = $binary; + } + + return self::fromArray($decoded); + } + + /** + * Serialize the CloudEvent to the JSON event format + * + * String data that is not valid UTF-8 (and therefore cannot be + * carried in the data member) is emitted as the data_base64 member. + * + * @see https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md + * + * @param int $flags json_encode() flags + * @return string + * @throws InvalidArgumentException + */ + public function toJson(int $flags = 0): string + { + $array = $this->toArray(); + + if (\is_string($this->data) && \preg_match('//u', $this->data) !== 1) { + unset($array['data']); + $array['data_base64'] = \base64_encode($this->data); + } + + try { + return \json_encode($array, $flags | JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new InvalidArgumentException('Unable to encode CloudEvent as JSON: ' . $e->getMessage(), 0, $e); + } + } + /** * Validate the CloudEvent * diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 25af4b4..40df90b 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -591,6 +591,113 @@ public function testExtensionRoundTrip(): void $this->assertEquals($original->extensions, $restored->extensions); } + public function testToJson(): void + { + $event = new CloudEvent( + type: 'user.created', + source: 'https://example.com/user-service', + id: 'event-1', + time: '2025-11-07T10:00:00Z', + datacontenttype: 'application/json', + data: ['userId' => '123'] + ); + + $decoded = json_decode($event->toJson(), true); + + $this->assertEquals([ + 'specversion' => '1.0', + 'type' => 'user.created', + 'source' => 'https://example.com/user-service', + 'id' => 'event-1', + 'time' => '2025-11-07T10:00:00Z', + 'datacontenttype' => 'application/json', + 'data' => ['userId' => '123'] + ], $decoded); + } + + public function testFromJson(): void + { + $json = '{"specversion":"1.0","type":"user.created","source":"user-service","id":"event-1","data":{"userId":"123"},"traceparent":"00-abc-def-01"}'; + + $event = CloudEvent::fromJson($json); + + $this->assertEquals('user.created', $event->type); + $this->assertEquals(['userId' => '123'], $event->data); + $this->assertEquals('00-abc-def-01', $event->getExtension('traceparent')); + } + + public function testFromJsonInvalidJson(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid CloudEvent JSON'); + + CloudEvent::fromJson('{not json'); + } + + public function testFromJsonNonObject(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('CloudEvent JSON must decode to an object'); + + CloudEvent::fromJson('"just a string"'); + } + + public function testJsonBinaryDataRoundTrip(): void + { + $binary = "\x89PNG\r\n\x1a\n\x00\x01\x02\x80\xff"; + + $event = new CloudEvent( + type: 'image.uploaded', + source: 'storage', + id: 'event-1', + datacontenttype: 'image/png', + data: $binary + ); + + $decoded = json_decode($event->toJson(), true); + + $this->assertArrayNotHasKey('data', $decoded); + $this->assertEquals(base64_encode($binary), $decoded['data_base64']); + + $restored = CloudEvent::fromJson($event->toJson()); + + $this->assertEquals($binary, $restored->data); + } + + public function testFromJsonRejectsDataAndDataBase64(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('CloudEvent must not contain both data and data_base64'); + + CloudEvent::fromJson('{"specversion":"1.0","type":"t","source":"s","id":"i","data":"x","data_base64":"eA=="}'); + } + + public function testFromJsonRejectsInvalidBase64(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('data_base64 must be valid Base64'); + + CloudEvent::fromJson('{"specversion":"1.0","type":"t","source":"s","id":"i","data_base64":"!!!not-base64!!!"}'); + } + + public function testJsonRoundTrip(): void + { + $original = (new CloudEvent( + type: 'payment.processed', + source: 'https://example.com/payments', + id: 'event-123', + subject: 'payment-xyz', + time: '2025-11-07T10:00:00Z', + datacontenttype: 'application/json', + dataschema: 'https://example.com/schemas/payment.json', + data: ['paymentId' => 'xyz'] + ))->withExtension('traceparent', '00-abc-def-01'); + + $restored = CloudEvent::fromJson($original->toJson()); + + $this->assertEquals($original, $restored); + } + public function testRoundTrip(): void { $original = new CloudEvent( From 5be68a87e56b50841d618f8f386090b4cee8fb54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 12:22:42 +0200 Subject: [PATCH 2/2] fix: preserve JSON data types and reject non-object roots in fromJson() Review feedback: associative decoding turned JSON objects inside data into PHP arrays, so an empty object {} was re-encoded as [] by toJson(), and a JSON array root slipped past the object check into fromArray() with a misleading missing-field error. fromJson() now decodes without assoc mode, requires a stdClass root, and keeps data as decoded JSON values (objects as stdClass), so object and array payloads keep their JSON type on re-encode. Co-Authored-By: Claude Fable 5 --- src/CloudEvents/CloudEvent.php | 10 +++++++--- tests/CloudEvents/CloudEventTest.php | 30 ++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index 39b2d6d..0250d56 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -147,7 +147,9 @@ public function toArray(): array * Create CloudEvent from its JSON event format representation * * Binary payloads carried in the data_base64 member are decoded - * into data. + * into data. JSON objects inside data are decoded as stdClass so + * that object and array payloads keep their JSON type when + * re-encoded (e.g., an empty object stays {} instead of []). * * @see https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md * @@ -158,15 +160,17 @@ public function toArray(): array public static function fromJson(string $json): self { try { - $decoded = \json_decode($json, true, 512, JSON_THROW_ON_ERROR); + $raw = \json_decode($json, false, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new InvalidArgumentException('Invalid CloudEvent JSON: ' . $e->getMessage(), 0, $e); } - if (!\is_array($decoded)) { + if (!$raw instanceof \stdClass) { throw new InvalidArgumentException('CloudEvent JSON must decode to an object'); } + $decoded = \get_object_vars($raw); + if (\array_key_exists('data_base64', $decoded)) { if (\array_key_exists('data', $decoded)) { throw new InvalidArgumentException('CloudEvent must not contain both data and data_base64'); diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 40df90b..5cc6810 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -622,10 +622,28 @@ public function testFromJson(): void $event = CloudEvent::fromJson($json); $this->assertEquals('user.created', $event->type); - $this->assertEquals(['userId' => '123'], $event->data); + $this->assertEquals((object) ['userId' => '123'], $event->data); $this->assertEquals('00-abc-def-01', $event->getExtension('traceparent')); } + public function testFromJsonPreservesJsonDataTypes(): void + { + $json = '{"specversion":"1.0","type":"t","source":"s","id":"i","data":{"empty":{},"list":[]}}'; + + $restored = CloudEvent::fromJson($json)->toJson(); + + $this->assertStringContainsString('"empty":{}', $restored); + $this->assertStringContainsString('"list":[]', $restored); + } + + public function testFromJsonRejectsArrayRoot(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('CloudEvent JSON must decode to an object'); + + CloudEvent::fromJson('[{"specversion":"1.0","type":"t","source":"s","id":"i"}]'); + } + public function testFromJsonInvalidJson(): void { $this->expectException(InvalidArgumentException::class); @@ -695,7 +713,15 @@ public function testJsonRoundTrip(): void $restored = CloudEvent::fromJson($original->toJson()); - $this->assertEquals($original, $restored); + $this->assertEquals($original->type, $restored->type); + $this->assertEquals($original->source, $restored->source); + $this->assertEquals($original->id, $restored->id); + $this->assertEquals($original->subject, $restored->subject); + $this->assertEquals($original->time, $restored->time); + $this->assertEquals($original->datacontenttype, $restored->datacontenttype); + $this->assertEquals($original->dataschema, $restored->dataschema); + $this->assertEquals($original->extensions, $restored->extensions); + $this->assertJsonStringEqualsJsonString($original->toJson(), $restored->toJson()); } public function testRoundTrip(): void