From eb07d9446a8a5420923b7ece7b4388d1d3a0f2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Jul 2026 13:07:39 +0200 Subject: [PATCH] feat: validate extension attributes in the constructor Extension attribute names and value types are now checked when a CloudEvent is constructed, so an event carrying invalid extensions can never exist. Previously the constructor accepted anything and the problem only surfaced when validate() was called. Since extensions are passed at construction time and the property is readonly, the redundant checks in fromArray() and validate() are removed; error messages are unchanged. Co-Authored-By: Claude Fable 5 --- README.md | 4 +-- src/CloudEvents/CloudEvent.php | 37 ++++++++++++---------------- tests/CloudEvents/CloudEventTest.php | 36 +++++++++++---------------- 3 files changed, 33 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 6548b2d..0b1fcb7 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,11 @@ $event->extensions['traceparent'] ?? null; // '00-4bf9...' $event->extensions; // all extension attributes ``` -Extension names must consist of lowercase letters and digits only, and values must be booleans, integers or strings. `CloudEvent` instances are immutable readonly value objects. +Extension names must consist of lowercase letters and digits only, and values must be booleans, integers or strings. These rules are enforced at construction — an invalid extension attribute makes the constructor (and therefore `fromArray()` and `fromJson()`) throw, so a `CloudEvent` instance never carries invalid extensions. `CloudEvent` instances are immutable readonly value objects. ### Validating a CloudEvent -`validate()` checks the event against the spec: the spec version is supported, `type`, `source` and `id` are non-empty, optional attributes are non-empty when present, and extension attributes follow the naming and type rules. +`validate()` checks the event against the spec: the spec version is supported, `type`, `source` and `id` are non-empty, and optional attributes are non-empty when present. Extension attributes are already validated at construction. ```php try { diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index 71bd6b5..b7caf4a 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -50,6 +50,7 @@ class CloudEvent * @param mixed $data Optional event payload of any type * @param string|null $dataschema Optional URI identifying the schema that data adheres to * @param array $extensions Extension attributes (lowercase alphanumeric names, boolean/integer/string values) + * @throws InvalidArgumentException When an extension attribute has an invalid name or value */ public function __construct( public readonly string $type, @@ -63,6 +64,13 @@ public function __construct( public readonly ?string $dataschema = null, public readonly array $extensions = [] ) { + 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'); + } + } } /** @@ -97,20 +105,12 @@ 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'); - } - } + // Null values mean the attribute is unset; the constructor + // validates whatever remains. + $extensions = \array_filter( + \array_diff_key($array, \array_flip(self::RESERVED_ATTRIBUTES)), + fn (mixed $value): bool => $value !== null + ); return new self( type: $array['type'], @@ -284,13 +284,8 @@ 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'); - } - } + // Extension attributes need no checks here: the constructor + // validates them, and readonly keeps the invariant intact. return true; } diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index cb56aeb..13db331 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -551,49 +551,43 @@ public function testFromArrayDropsNullExtensions(): void $this->assertArrayNotHasKey('traceparent', $event->toArray()); } - public function testValidateRejectsInvalidExtensionName(): void + public function testConstructorRejectsInvalidExtensionName(): void { - $event = new CloudEvent( + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Extension attribute name must contain only lowercase letters and digits'); + + new CloudEvent( type: 'test.event', source: 'test-service', id: 'test-id', extensions: ['Trace_Parent' => 'value'] ); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Extension attribute name must contain only lowercase letters and digits'); - - $event->validate(); } - public function testValidateRejectsReservedExtensionName(): void + public function testConstructorRejectsReservedExtensionName(): void { - $event = new CloudEvent( + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Extension attribute name conflicts with a core attribute: data'); + + new CloudEvent( type: 'test.event', source: 'test-service', id: 'test-id', extensions: ['data' => 'value'] ); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Extension attribute name conflicts with a core attribute: data'); - - $event->validate(); } - public function testValidateRejectsInvalidExtensionValue(): void + public function testConstructorRejectsInvalidExtensionValue(): void { - $event = new CloudEvent( + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Extension attribute "myext" must be a boolean, integer or string'); + + 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