From 7bee9fd2ee3ba900d6b8cd04f5448530c6fb4e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 11:52:07 +0200 Subject: [PATCH 01/11] fix: allow any data type and omit absent optional attributes Per the CloudEvents v1.0 spec, data may be of any type (JSON value, plain text, binary), datacontenttype is optional with no default, and null is not a valid attribute value. - Type data as mixed (null when absent) instead of forcing an array - Stop injecting a default datacontenttype in the constructor and fromArray(), so round-tripping no longer fabricates an attribute - Omit absent optional attributes from toArray() instead of emitting null values - Reject empty datacontenttype in validate() Co-Authored-By: Claude Fable 5 --- src/CloudEvents/CloudEvent.php | 43 ++++++++++++++----- tests/CloudEvents/CloudEventTest.php | 62 ++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index 4def5c0..da86460 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -19,8 +19,8 @@ class CloudEvent * @param string $specversion CloudEvents spec version (default: "1.0") * @param string|null $subject Optional subject of the event in the context of the source * @param string|null $time Optional event timestamp in RFC 3339 format - * @param string $datacontenttype Content type of data (default: "application/json") - * @param array $data Event data payload + * @param string|null $datacontenttype Optional content type of data (RFC 2046, e.g., "application/json") + * @param mixed $data Optional event payload of any type */ public function __construct( public readonly string $type, @@ -29,8 +29,8 @@ public function __construct( public readonly string $specversion = '1.0', public readonly ?string $subject = null, public readonly ?string $time = null, - public readonly string $datacontenttype = 'application/json', - public readonly array $data = [] + public readonly ?string $datacontenttype = null, + public readonly mixed $data = null ) { } @@ -60,28 +60,45 @@ public static function fromArray(array $array): self specversion: $array['specversion'], subject: $array['subject'] ?? null, time: $array['time'] ?? null, - datacontenttype: $array['datacontenttype'] ?? 'application/json', - data: $array['data'] ?? [] + datacontenttype: $array['datacontenttype'] ?? null, + data: $array['data'] ?? null ); } /** * Convert CloudEvent to array * + * Optional attributes that are absent are omitted, since the spec + * does not allow null attribute values. + * * @return array */ public function toArray(): array { - return [ + $array = [ 'specversion' => $this->specversion, 'type' => $this->type, 'source' => $this->source, - 'subject' => $this->subject, 'id' => $this->id, - 'time' => $this->time, - 'datacontenttype' => $this->datacontenttype, - 'data' => $this->data ]; + + if ($this->subject !== null) { + $array['subject'] = $this->subject; + } + + if ($this->time !== null) { + $array['time'] = $this->time; + } + + if ($this->datacontenttype !== null) { + $array['datacontenttype'] = $this->datacontenttype; + } + + if ($this->data !== null) { + $array['data'] = $this->data; + } + + return $array; } /** @@ -116,6 +133,10 @@ public function validate(): bool throw new InvalidArgumentException('Event time must not be empty when present'); } + if ($this->datacontenttype === '') { + throw new InvalidArgumentException('Event datacontenttype must not be empty when present'); + } + return true; } } diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 63bf093..0e9bb25 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -45,8 +45,8 @@ public function testConstructorWithDefaults(): void $this->assertNull($event->subject); $this->assertEquals('test-id', $event->id); $this->assertNull($event->time); - $this->assertEquals('application/json', $event->datacontenttype); - $this->assertEquals([], $event->data); + $this->assertNull($event->datacontenttype); + $this->assertNull($event->data); } public function testFromArray(): void @@ -87,8 +87,8 @@ public function testFromArrayWithMissingOptionalFields(): void $this->assertNull($event->subject); $this->assertNull($event->time); - $this->assertEquals('application/json', $event->datacontenttype); - $this->assertEquals([], $event->data); + $this->assertNull($event->datacontenttype); + $this->assertNull($event->data); } public function testFromArrayMissingSpecversion(): void @@ -214,19 +214,63 @@ public function testToArray(): void ], $array); } - public function testToArrayWithNullSubject(): void + public function testToArrayOmitsAbsentOptionalAttributes(): void { $event = new CloudEvent( - specversion: '1.0', type: 'test.event', source: 'test-service', - id: 'test-id', - time: '2025-11-07T10:00:00Z' + id: 'test-id' ); $array = $event->toArray(); - $this->assertNull($array['subject']); + $this->assertEquals([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id' + ], $array); + $this->assertArrayNotHasKey('subject', $array); + $this->assertArrayNotHasKey('time', $array); + $this->assertArrayNotHasKey('datacontenttype', $array); + $this->assertArrayNotHasKey('data', $array); + } + + public function testDataAcceptsAnyType(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + datacontenttype: 'text/plain', + data: 'plain text payload' + ); + + $this->assertEquals('plain text payload', $event->data); + $this->assertEquals('plain text payload', $event->toArray()['data']); + + $event = CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'data' => 42 + ]); + + $this->assertEquals(42, $event->data); + } + + public function testFromArrayDoesNotFabricateDatacontenttype(): void + { + $event = CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id' + ]); + + $this->assertNull($event->datacontenttype); + $this->assertArrayNotHasKey('datacontenttype', $event->toArray()); } public function testValidate(): void From e5b338b863a6af5dd897fa601f6c17d81415193d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 11:52:43 +0200 Subject: [PATCH 02/11] feat: add dataschema context attribute Adds the optional dataschema attribute from the CloudEvents v1.0 spec, a URI identifying the schema that data adheres to. It is included in fromArray()/toArray() round-trips, omitted when absent, and rejected by validate() when present but empty. Co-Authored-By: Claude Fable 5 --- src/CloudEvents/CloudEvent.php | 11 +++++++ tests/CloudEvents/CloudEventTest.php | 44 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index da86460..01d4cc4 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -20,6 +20,7 @@ class CloudEvent * @param string|null $subject Optional subject of the event in the context of the source * @param string|null $time Optional event timestamp in RFC 3339 format * @param string|null $datacontenttype Optional content type of data (RFC 2046, e.g., "application/json") + * @param string|null $dataschema Optional URI identifying the schema that data adheres to * @param mixed $data Optional event payload of any type */ public function __construct( @@ -30,6 +31,7 @@ public function __construct( public readonly ?string $subject = null, public readonly ?string $time = null, public readonly ?string $datacontenttype = null, + public readonly ?string $dataschema = null, public readonly mixed $data = null ) { } @@ -61,6 +63,7 @@ public static function fromArray(array $array): self subject: $array['subject'] ?? null, time: $array['time'] ?? null, datacontenttype: $array['datacontenttype'] ?? null, + dataschema: $array['dataschema'] ?? null, data: $array['data'] ?? null ); } @@ -94,6 +97,10 @@ public function toArray(): array $array['datacontenttype'] = $this->datacontenttype; } + if ($this->dataschema !== null) { + $array['dataschema'] = $this->dataschema; + } + if ($this->data !== null) { $array['data'] = $this->data; } @@ -137,6 +144,10 @@ public function validate(): bool throw new InvalidArgumentException('Event datacontenttype must not be empty when present'); } + if ($this->dataschema === '') { + throw new InvalidArgumentException('Event dataschema must not be empty when present'); + } + return true; } } diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 0e9bb25..87d1ad1 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -260,6 +260,50 @@ public function testDataAcceptsAnyType(): void $this->assertEquals(42, $event->data); } + public function testDataschema(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + dataschema: 'https://example.com/schemas/user.json' + ); + + $this->assertEquals('https://example.com/schemas/user.json', $event->dataschema); + $this->assertEquals('https://example.com/schemas/user.json', $event->toArray()['dataschema']); + $this->assertTrue($event->validate()); + + $restored = CloudEvent::fromArray($event->toArray()); + $this->assertEquals($event->dataschema, $restored->dataschema); + } + + public function testDataschemaAbsent(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id' + ); + + $this->assertNull($event->dataschema); + $this->assertArrayNotHasKey('dataschema', $event->toArray()); + } + + public function testValidateEmptyDataschema(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Event dataschema must not be empty when present'); + + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + dataschema: '' + ); + + $event->validate(); + } + public function testFromArrayDoesNotFabricateDatacontenttype(): void { $event = CloudEvent::fromArray([ From 7af61a4c2e36eab9a88257f8193e090414d51d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 11:54:11 +0200 Subject: [PATCH 03/11] feat: add extension attribute support Implements the CloudEvents v1.0 extension attribute mechanism (traceparent, partitionkey, etc.), previously dropped silently by fromArray(). - withExtension() returns a copy with the extension set, failing fast on names that are not lowercase alphanumeric or that collide with core attribute names - getExtension() reads a single extension, extensions exposes them all - fromArray() collects unknown keys as extension attributes - toArray() serializes extensions alongside core attributes - validate() enforces the spec naming rules and the boolean/integer/ string value types Co-Authored-By: Claude Fable 5 --- src/CloudEvents/CloudEvent.php | 88 +++++++++++++++++++++- tests/CloudEvents/CloudEventTest.php | 106 +++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 3 deletions(-) diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index 01d4cc4..3cb065c 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -10,6 +10,22 @@ */ class CloudEvent { + /** + * Names reserved for core context attributes, which extension + * attributes must not use. + */ + private const RESERVED_ATTRIBUTES = [ + 'specversion', + 'type', + 'source', + 'id', + 'subject', + 'time', + 'datacontenttype', + 'dataschema', + 'data', + ]; + /** * CloudEvent constructor * @@ -22,6 +38,7 @@ class CloudEvent * @param string|null $datacontenttype Optional content type of data (RFC 2046, e.g., "application/json") * @param string|null $dataschema Optional URI identifying the schema that data adheres to * @param mixed $data Optional event payload of any type + * @param array $extensions Extension attributes (lowercase alphanumeric names, boolean/integer/string values) */ public function __construct( public readonly string $type, @@ -32,10 +49,48 @@ public function __construct( public readonly ?string $time = null, public readonly ?string $datacontenttype = null, public readonly ?string $dataschema = null, - public readonly mixed $data = null + public readonly mixed $data = null, + public readonly array $extensions = [] ) { } + /** + * Return a copy of the event with the given extension attribute set + * + * @param string $name + * @param bool|int|string $value + * @return self + * @throws InvalidArgumentException + */ + public function withExtension(string $name, bool|int|string $value): self + { + self::assertValidExtensionName($name); + + return new self( + type: $this->type, + source: $this->source, + id: $this->id, + specversion: $this->specversion, + subject: $this->subject, + time: $this->time, + datacontenttype: $this->datacontenttype, + dataschema: $this->dataschema, + data: $this->data, + extensions: \array_merge($this->extensions, [$name => $value]) + ); + } + + /** + * Get an extension attribute value, or null when not set + * + * @param string $name + * @return mixed + */ + public function getExtension(string $name): mixed + { + return $this->extensions[$name] ?? null; + } + /** * Create CloudEvent from array * @@ -64,7 +119,8 @@ public static function fromArray(array $array): self time: $array['time'] ?? null, datacontenttype: $array['datacontenttype'] ?? null, dataschema: $array['dataschema'] ?? null, - data: $array['data'] ?? null + data: $array['data'] ?? null, + extensions: \array_diff_key($array, \array_flip(self::RESERVED_ATTRIBUTES)) ); } @@ -105,7 +161,7 @@ public function toArray(): array $array['data'] = $this->data; } - return $array; + return $array + $this->extensions; } /** @@ -148,6 +204,32 @@ public function validate(): bool throw new InvalidArgumentException('Event dataschema must not be empty when present'); } + foreach ($this->extensions as $name => $value) { + self::assertValidExtensionName((string) $name); + + if (!\is_bool($value) && !\is_int($value) && !\is_string($value)) { + throw new InvalidArgumentException('Extension attribute "' . $name . '" must be a boolean, integer or string'); + } + } + return true; } + + /** + * Assert that a name is a valid, non-reserved extension attribute name + * + * @param string $name + * @return void + * @throws InvalidArgumentException + */ + private static function assertValidExtensionName(string $name): void + { + if (!\preg_match('/^[a-z0-9]+$/', $name)) { + throw new InvalidArgumentException('Extension attribute name must contain only lowercase letters and digits: ' . $name); + } + + if (\in_array($name, self::RESERVED_ATTRIBUTES, true)) { + throw new InvalidArgumentException('Extension attribute name conflicts with a core attribute: ' . $name); + } + } } diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 87d1ad1..9b9297a 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -416,6 +416,112 @@ public function testValidateWithoutTime(): void $this->assertTrue($event->validate()); } + public function testWithExtension(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id' + ); + + $extended = $event + ->withExtension('traceparent', '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01') + ->withExtension('sequence', 42) + ->withExtension('sampled', true); + + $this->assertEquals('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', $extended->getExtension('traceparent')); + $this->assertEquals(42, $extended->getExtension('sequence')); + $this->assertTrue($extended->getExtension('sampled')); + $this->assertTrue($extended->validate()); + + $this->assertEquals([], $event->extensions); + $this->assertNull($event->getExtension('traceparent')); + } + + public function testToArrayIncludesExtensions(): void + { + $event = (new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id' + ))->withExtension('partitionkey', 'shard-1'); + + $array = $event->toArray(); + + $this->assertEquals('shard-1', $array['partitionkey']); + } + + public function testFromArrayCollectsExtensions(): void + { + $event = CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'traceparent' => '00-abc-def-01', + 'sequence' => 7 + ]); + + $this->assertEquals(['traceparent' => '00-abc-def-01', 'sequence' => 7], $event->extensions); + $this->assertEquals('00-abc-def-01', $event->getExtension('traceparent')); + } + + public function testWithExtensionRejectsInvalidName(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id' + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Extension attribute name must contain only lowercase letters and digits'); + + $event->withExtension('Trace_Parent', 'value'); + } + + public function testWithExtensionRejectsReservedName(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id' + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Extension attribute name conflicts with a core attribute: data'); + + $event->withExtension('data', 'value'); + } + + public function testValidateRejectsInvalidExtensionValue(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + extensions: ['myext' => ['nested' => 'array']] + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Extension attribute "myext" must be a boolean, integer or string'); + + $event->validate(); + } + + public function testExtensionRoundTrip(): void + { + $original = (new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id' + ))->withExtension('traceparent', '00-abc-def-01'); + + $restored = CloudEvent::fromArray($original->toArray()); + + $this->assertEquals($original->extensions, $restored->extensions); + } + public function testRoundTrip(): void { $original = new CloudEvent( From f45911163e1d7c0d92bfe572283977fe9c34b5bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 12:06:30 +0200 Subject: [PATCH 04/11] docs: document the dataschema attribute Adds dataschema to the README property reference and notes that absent optional attributes are omitted from toArray(), while present ones must not be empty. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 55f08c2..10b09d8 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,11 @@ The `CloudEvent` class supports the following properties according to the CloudE - **id** (required): Unique identifier for the event - **time** (required): Timestamp when the event occurred (RFC3339 format) - **datacontenttype** (optional): Content type of the data field (default: "application/json") +- **dataschema** (optional): URI identifying the schema that the data field adheres to - **data** (required): Event payload as an array +Optional attributes are omitted from `toArray()` when absent, since the spec does not allow null attribute values. When present, they must not be empty — `validate()` rejects an empty `dataschema`. + ## Use Cases - **Event-Driven Architecture**: Standardize event formats across microservices From 79c6c0f65f48f9c084811101a9fedf69773a625d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 12:11:31 +0200 Subject: [PATCH 05/11] refactor: declare dataschema after data in the constructor Keeps data in its existing constructor position so the new optional attribute is appended rather than inserted ahead of the payload. Co-Authored-By: Claude Opus 5 (1M context) --- src/CloudEvents/CloudEvent.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index 01d4cc4..409e167 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -20,8 +20,8 @@ class CloudEvent * @param string|null $subject Optional subject of the event in the context of the source * @param string|null $time Optional event timestamp in RFC 3339 format * @param string|null $datacontenttype Optional content type of data (RFC 2046, e.g., "application/json") - * @param string|null $dataschema Optional URI identifying the schema that data adheres to * @param mixed $data Optional event payload of any type + * @param string|null $dataschema Optional URI identifying the schema that data adheres to */ public function __construct( public readonly string $type, @@ -31,8 +31,8 @@ public function __construct( public readonly ?string $subject = null, public readonly ?string $time = null, public readonly ?string $datacontenttype = null, - public readonly ?string $dataschema = null, - public readonly mixed $data = null + public readonly mixed $data = null, + public readonly ?string $dataschema = null ) { } @@ -63,8 +63,8 @@ public static function fromArray(array $array): self subject: $array['subject'] ?? null, time: $array['time'] ?? null, datacontenttype: $array['datacontenttype'] ?? null, - dataschema: $array['dataschema'] ?? null, - data: $array['data'] ?? null + data: $array['data'] ?? null, + dataschema: $array['dataschema'] ?? null ); } From fcb2de966424817f5f8755c3db408b96262945b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 12:18:58 +0200 Subject: [PATCH 06/11] fix: reject whitespace-only datacontenttype in validate() Review feedback: a datacontenttype of only whitespace passed the exact-empty-string check while still being an invalid media type. Co-Authored-By: Claude Fable 5 --- src/CloudEvents/CloudEvent.php | 2 +- tests/CloudEvents/CloudEventTest.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index da86460..05ffd96 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -133,7 +133,7 @@ public function validate(): bool throw new InvalidArgumentException('Event time must not be empty when present'); } - if ($this->datacontenttype === '') { + if ($this->datacontenttype !== null && \trim($this->datacontenttype) === '') { throw new InvalidArgumentException('Event datacontenttype must not be empty when present'); } diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 0e9bb25..26d2157 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -260,6 +260,21 @@ public function testDataAcceptsAnyType(): void $this->assertEquals(42, $event->data); } + public function testValidateRejectsBlankDatacontenttype(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Event datacontenttype must not be empty when present'); + + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + datacontenttype: ' ' + ); + + $event->validate(); + } + public function testFromArrayDoesNotFabricateDatacontenttype(): void { $event = CloudEvent::fromArray([ From 71b3d8a58cada22e5ea90cc54af58159a2a0f33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 12:21:36 +0200 Subject: [PATCH 07/11] fix: validate extension attributes in fromArray() Review feedback: fromArray() stored unknown keys without applying the extension name and value-type rules, so a malformed event survived parsing and was re-emitted by toArray(). fromArray() now enforces the same rules as withExtension() and validate(), and drops null-valued extensions, which the JSON format defines as equivalent to absent. Co-Authored-By: Claude Fable 5 --- src/CloudEvents/CloudEvent.php | 17 ++++++++++- tests/CloudEvents/CloudEventTest.php | 42 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index 2a694f0..280e119 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -110,6 +110,21 @@ public static function fromArray(array $array): self throw new InvalidArgumentException('Unsupported CloudEvents spec version: ' . $array['specversion']); } + $extensions = \array_diff_key($array, \array_flip(self::RESERVED_ATTRIBUTES)); + + foreach ($extensions as $name => $value) { + if ($value === null) { + unset($extensions[$name]); + continue; + } + + self::assertValidExtensionName((string) $name); + + if (!\is_bool($value) && !\is_int($value) && !\is_string($value)) { + throw new InvalidArgumentException('Extension attribute "' . $name . '" must be a boolean, integer or string'); + } + } + return new self( type: $array['type'], source: $array['source'], @@ -120,7 +135,7 @@ public static function fromArray(array $array): self datacontenttype: $array['datacontenttype'] ?? null, data: $array['data'] ?? null, dataschema: $array['dataschema'] ?? null, - extensions: \array_diff_key($array, \array_flip(self::RESERVED_ATTRIBUTES)) + extensions: $extensions ); } diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 8cc667b..f8845d7 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -481,6 +481,48 @@ public function testFromArrayCollectsExtensions(): void $this->assertEquals('00-abc-def-01', $event->getExtension('traceparent')); } + public function testFromArrayRejectsInvalidExtensionName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Extension attribute name must contain only lowercase letters and digits'); + + CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'Trace_Parent' => 'value' + ]); + } + + public function testFromArrayRejectsInvalidExtensionValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Extension attribute "myext" must be a boolean, integer or string'); + + CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'myext' => ['nested' => 'array'] + ]); + } + + public function testFromArrayDropsNullExtensions(): void + { + $event = CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'traceparent' => null + ]); + + $this->assertEquals([], $event->extensions); + $this->assertArrayNotHasKey('traceparent', $event->toArray()); + } + public function testWithExtensionRejectsInvalidName(): void { $event = new CloudEvent( From 765807012f922b893f1498ba6a84a2ba1e338a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 13:01:05 +0200 Subject: [PATCH 08/11] refactor: drop withExtension() and getExtension() accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team convention: no getters or withers — with modern PHP the readonly extensions property declared in the constructor is used directly. Extensions are passed at construction time and read via $event->extensions; the name and value rules are still enforced by fromArray() and validate(). Co-Authored-By: Claude Fable 5 --- src/CloudEvents/CloudEvent.php | 37 ----------------- tests/CloudEvents/CloudEventTest.php | 60 +++++++++++++++++----------- 2 files changed, 36 insertions(+), 61 deletions(-) diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index 280e119..41bdaff 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -54,43 +54,6 @@ public function __construct( ) { } - /** - * Return a copy of the event with the given extension attribute set - * - * @param string $name - * @param bool|int|string $value - * @return self - * @throws InvalidArgumentException - */ - public function withExtension(string $name, bool|int|string $value): self - { - self::assertValidExtensionName($name); - - return new self( - type: $this->type, - source: $this->source, - id: $this->id, - specversion: $this->specversion, - subject: $this->subject, - time: $this->time, - datacontenttype: $this->datacontenttype, - dataschema: $this->dataschema, - data: $this->data, - extensions: \array_merge($this->extensions, [$name => $value]) - ); - } - - /** - * Get an extension attribute value, or null when not set - * - * @param string $name - * @return mixed - */ - public function getExtension(string $name): mixed - { - return $this->extensions[$name] ?? null; - } - /** * Create CloudEvent from array * diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index f8845d7..25af4b4 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -431,35 +431,44 @@ public function testValidateWithoutTime(): void $this->assertTrue($event->validate()); } - public function testWithExtension(): void + public function testExtensions(): void { $event = new CloudEvent( type: 'test.event', source: 'test-service', - id: 'test-id' + id: 'test-id', + extensions: [ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + 'sequence' => 42, + 'sampled' => true, + ] ); - $extended = $event - ->withExtension('traceparent', '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01') - ->withExtension('sequence', 42) - ->withExtension('sampled', true); + $this->assertEquals('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', $event->extensions['traceparent']); + $this->assertEquals(42, $event->extensions['sequence']); + $this->assertTrue($event->extensions['sampled']); + $this->assertTrue($event->validate()); + } - $this->assertEquals('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', $extended->getExtension('traceparent')); - $this->assertEquals(42, $extended->getExtension('sequence')); - $this->assertTrue($extended->getExtension('sampled')); - $this->assertTrue($extended->validate()); + public function testExtensionsDefaultToEmpty(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id' + ); $this->assertEquals([], $event->extensions); - $this->assertNull($event->getExtension('traceparent')); } public function testToArrayIncludesExtensions(): void { - $event = (new CloudEvent( + $event = new CloudEvent( type: 'test.event', source: 'test-service', - id: 'test-id' - ))->withExtension('partitionkey', 'shard-1'); + id: 'test-id', + extensions: ['partitionkey' => 'shard-1'] + ); $array = $event->toArray(); @@ -478,7 +487,7 @@ public function testFromArrayCollectsExtensions(): void ]); $this->assertEquals(['traceparent' => '00-abc-def-01', 'sequence' => 7], $event->extensions); - $this->assertEquals('00-abc-def-01', $event->getExtension('traceparent')); + $this->assertEquals('00-abc-def-01', $event->extensions['traceparent']); } public function testFromArrayRejectsInvalidExtensionName(): void @@ -523,32 +532,34 @@ public function testFromArrayDropsNullExtensions(): void $this->assertArrayNotHasKey('traceparent', $event->toArray()); } - public function testWithExtensionRejectsInvalidName(): void + public function testValidateRejectsInvalidExtensionName(): void { $event = new CloudEvent( type: 'test.event', source: 'test-service', - id: 'test-id' + id: 'test-id', + extensions: ['Trace_Parent' => 'value'] ); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Extension attribute name must contain only lowercase letters and digits'); - $event->withExtension('Trace_Parent', 'value'); + $event->validate(); } - public function testWithExtensionRejectsReservedName(): void + public function testValidateRejectsReservedExtensionName(): void { $event = new CloudEvent( type: 'test.event', source: 'test-service', - id: 'test-id' + id: 'test-id', + extensions: ['data' => 'value'] ); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Extension attribute name conflicts with a core attribute: data'); - $event->withExtension('data', 'value'); + $event->validate(); } public function testValidateRejectsInvalidExtensionValue(): void @@ -568,11 +579,12 @@ public function testValidateRejectsInvalidExtensionValue(): void public function testExtensionRoundTrip(): void { - $original = (new CloudEvent( + $original = new CloudEvent( type: 'test.event', source: 'test-service', - id: 'test-id' - ))->withExtension('traceparent', '00-abc-def-01'); + id: 'test-id', + extensions: ['traceparent' => '00-abc-def-01'] + ); $restored = CloudEvent::fromArray($original->toArray()); 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 09/11] 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 10/11] 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 From f20f5b84d75d37a4d9340afd5efa81c5c5c98e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 31 Jul 2026 11:59:41 +0200 Subject: [PATCH 11/11] fix: update JSON tests for the removed extension accessors PR #9 dropped withExtension()/getExtension(), but PR #10's tests were written before that and still called them, so the merge of both left the suite failing. Use the readonly extensions property instead. Co-Authored-By: Claude Opus 5 (1M context) --- tests/CloudEvents/CloudEventTest.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 5cc6810..1cde457 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -623,7 +623,7 @@ public function testFromJson(): void $this->assertEquals('user.created', $event->type); $this->assertEquals((object) ['userId' => '123'], $event->data); - $this->assertEquals('00-abc-def-01', $event->getExtension('traceparent')); + $this->assertEquals('00-abc-def-01', $event->extensions['traceparent']); } public function testFromJsonPreservesJsonDataTypes(): void @@ -700,7 +700,7 @@ public function testFromJsonRejectsInvalidBase64(): void public function testJsonRoundTrip(): void { - $original = (new CloudEvent( + $original = new CloudEvent( type: 'payment.processed', source: 'https://example.com/payments', id: 'event-123', @@ -708,8 +708,9 @@ public function testJsonRoundTrip(): void 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'); + data: ['paymentId' => 'xyz'], + extensions: ['traceparent' => '00-abc-def-01'] + ); $restored = CloudEvent::fromJson($original->toJson());