From 9cbc8dfe6b4ad5273b4ed1729f5a35112f20660d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Jul 2026 15:37:00 +0200 Subject: [PATCH 1/3] Implement more features for cloudevents --- README.md | 90 +++++- src/CloudEvents/CloudEvent.php | 383 +++++++++++++++++++--- src/CloudEvents/Exception.php | 16 + tests/CloudEvents/CloudEventTest.php | 466 +++++++++++++++++++++++++++ 4 files changed, 912 insertions(+), 43 deletions(-) create mode 100644 src/CloudEvents/Exception.php diff --git a/README.md b/README.md index 55f08c2..adb95d0 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,42 @@ $event = new CloudEvent( source: 'user-service', subject: 'user-123', id: uniqid(), - time: date('c'), + time: CloudEvent::now(), datacontenttype: 'application/json', data: [ 'userId' => '123', 'email' => 'user@example.com', 'name' => 'John Doe' - ] + ], + dataschema: 'https://example.com/schemas/user.json', + extensions: ['traceparent' => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'] ); ``` +`CloudEvent::now()` returns an RFC 3339 UTC timestamp with milliseconds +(`2025-11-07T10:00:00.123Z`). Prefer it over `date('c')`, which renders UTC as +`+00:00` and carries no sub-second part. + +### Building an event in stages + +The object is immutable, so every `with*()` method returns a new instance. This +is handy when the transport assigns the identity of the event — the broker or +log allocates the `id`, and `time` is stamped at publish: + +```php +$event = new CloudEvent(type: 'user.created', source: 'user-service'); + +$published = $event + ->withId($broker->nextId()) + ->withSubject('user-123') + ->withData(['userId' => '123']) + ->withExtension('traceparent', $trace) + ->withTime(); // defaults to CloudEvent::now() +``` + +`withSource()` is available too. Passing `null` to `withExtension()` unsets that +extension attribute. + ### Converting to Array ```php @@ -77,29 +103,79 @@ $eventData = [ $event = CloudEvent::fromArray($eventData); ``` +Any member that is not a spec attribute is carried as an extension attribute, and +`toArray()` emits it again, so a round trip is lossless. Per the JSON format, an +attribute whose value is `null` is treated as unset. + +### Strict and lenient decoding + +`fromArray()` is strict by default: anything malformed raises +`Utopia\CloudEvents\Exception`. That is what you want when decoding an event from +a peer you control. + +When you consume a public stream, one bad optional attribute should not cost you +the whole event. Lenient mode coerces malformed optional attributes to their +default and drops invalid extension attributes: + +```php +// A non-string `subject` becomes null instead of raising +$event = CloudEvent::fromArray($raw, lenient: true); + +// Also survive a producer that has moved to a spec version this library +// does not know. The version is kept verbatim; validate() still rejects it. +$event = CloudEvent::fromArray($raw, lenient: true, allowUnknownSpecversion: true); +``` + +Lenient mode never invents a required attribute: a missing `specversion` or a +missing or empty `type` still raises. Neither mode enforces the presence of `id` +and `source` — call `validate()` for a full conformance check. + ### Validating a CloudEvent +`validate()` enforces the four REQUIRED context attributes: `id`, `source`, +`specversion` and `type`. + ```php +use Utopia\CloudEvents\Exception as CloudEventException; + try { $event->validate(); echo "Event is valid!"; -} catch (InvalidArgumentException $e) { +} catch (CloudEventException $e) { echo "Event validation failed: " . $e->getMessage(); } ``` +Every malformed-input path throws `Utopia\CloudEvents\Exception`, which extends +`InvalidArgumentException`. + ## CloudEvent Properties The `CloudEvent` class supports the following properties according to the CloudEvents v1.0 specification: - **specversion** (required): CloudEvents specification version (default: "1.0") - **type** (required): Event type identifier (e.g., "user.created", "v1-stats-usage") -- **source** (required): Context in which the event occurred (e.g., service name) +- **source** (required): Context in which the event occurred, a non-empty URI-reference (e.g., service name) +- **id** (required): Unique, non-empty identifier for the event - **subject** (optional): Subject of the event (e.g., project ID, user ID) -- **id** (required): Unique identifier for the event -- **time** (required): Timestamp when the event occurred (RFC3339 format) +- **time** (optional): Timestamp when the event occurred (RFC 3339 format) - **datacontenttype** (optional): Content type of the data field (default: "application/json") -- **data** (required): Event payload as an array +- **dataschema** (optional): URI identifying the schema that `data` adheres to +- **data** (optional): Event payload. The JSON format leaves this unrestricted, so an array, string, number, boolean or `null` are all valid. + +### Extension attributes + +An event may carry any number of extension context attributes, such as +`traceparent`. Names are restricted by the spec to lowercase `a-z` and `0-9`, and +values must be scalar; anything else raises in strict mode. + +```php +$event = $event->withExtension('traceparent', $trace); + +$event->getExtension('traceparent'); // the value, or null +$event->getExtension('retrycount', 0); // with a default +$event->getExtensions(); // all of them, keyed by name +``` ## Use Cases diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index d42a443..efbb3be 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -2,79 +2,197 @@ namespace Utopia\CloudEvents; -use InvalidArgumentException; +use DateTimeImmutable; +use DateTimeZone; /** * CloudEvent class representing the CloudEvents v1.0 specification + * * @see https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md + * @see https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md */ class CloudEvent { + /** + * The only spec version this library implements. + */ + public const SPECVERSION = '1.0'; + + /** + * RFC 3339 (UTC, millisecond precision) format string. + * + * PHP's DATE_ATOM renders UTC as "+00:00" and carries no sub-second part, + * so it is not used here. + */ + public const TIME_FORMAT = 'Y-m-d\TH:i:s.v\Z'; + + /** + * Attribute names owned by the spec, which therefore may not be used as + * extension attribute names. + * + * @var array + */ + private const RESERVED = [ + 'specversion', + 'type', + 'source', + 'subject', + 'id', + 'time', + 'datacontenttype', + 'dataschema', + 'data', + 'data_base64', + ]; + + /** + * Extension context attributes, keyed by attribute name. + * + * @var array + */ + public readonly array $extensions; + /** * CloudEvent constructor * - * @param string $specversion CloudEvents spec version (default: "1.0") - * @param string $type Event type that maps to worker (e.g., "v1-stats-usage") - * @param string $source Event source (e.g., "imagine") - * @param string|null $subject Optional subject, typically project ID - * @param string $id Unique event identifier - * @param string $time Event timestamp in RFC3339 format - * @param string $datacontenttype Content type of data (default: "application/json") - * @param array $data Event data payload + * @param string $specversion CloudEvents spec version (default: "1.0") + * @param string $type Event type that maps to worker (e.g., "v1-stats-usage") + * @param string $source Event source, a non-empty URI-reference (e.g., "imagine") + * @param string|null $subject Optional subject, typically project ID + * @param string $id Unique event identifier + * @param string $time Event timestamp in RFC 3339 format, see self::now() + * @param string $datacontenttype Content type of data (default: "application/json") + * @param mixed $data Event data payload. The JSON format leaves this unrestricted, + * so an array, string, number, boolean or null are all valid. + * @param string|null $dataschema Optional URI identifying the schema of $data + * @param array $extensions Extension context attributes. Names must + * consist of lowercase a-z and 0-9 only and + * must not collide with a spec attribute. + * + * @throws Exception on an invalid extension attribute name or value */ public function __construct( - public readonly string $specversion = '1.0', + public readonly string $specversion = self::SPECVERSION, public readonly string $type = '', public readonly string $source = '', public readonly ?string $subject = null, public readonly string $id = '', public readonly string $time = '', public readonly string $datacontenttype = 'application/json', - public readonly array $data = [] + public readonly mixed $data = [], + public readonly ?string $dataschema = null, + array $extensions = [], ) { + $this->extensions = self::filterExtensions($extensions, lenient: false); + } + + /** + * Current time as an RFC 3339 UTC timestamp with milliseconds + * + * Produces e.g. "2025-11-07T10:00:00.123Z", which is what the `time` + * attribute expects. + */ + public static function now(): string + { + return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(self::TIME_FORMAT); } /** * Create CloudEvent from array * - * @param array $array - * @return self - * @throws InvalidArgumentException + * Per the JSON format, an attribute whose value is null is treated as unset, + * and any member that is not a spec attribute is carried as an extension. + * + * Strict mode (the default) raises an Exception when: + * - `specversion` is missing, or is not the string "1.0"; + * - `type` is missing, empty, or not a string; + * - any other spec attribute is present with a non-string value; + * - an extension attribute has an invalid name or a non-scalar value. + * + * Lenient mode ($lenient = true) raises an Exception only for the `specversion` + * and `type` failures above; it never invents a required attribute. Every other + * malformed attribute is coerced to its default (so a non-string `subject` + * becomes null) and every invalid extension attribute is dropped, so a single + * bad optional attribute from an uncontrolled producer still yields a usable + * event. Pass $allowUnknownSpecversion to also survive a producer that has + * moved to a spec version this library does not know; the unknown version is + * kept verbatim on the returned event, where validate() will still reject it. + * + * Neither mode enforces the presence of `id` and `source`; call validate() for + * a full conformance check. + * + * @param array $array + * @param bool $lenient Coerce malformed optional attributes instead of throwing + * @param bool $allowUnknownSpecversion Accept an unknown spec version (lenient mode only) + * + * @throws Exception */ - public static function fromArray(array $array): self + public static function fromArray(array $array, bool $lenient = false, bool $allowUnknownSpecversion = false): self { if (!isset($array['specversion'])) { - throw new InvalidArgumentException('Missing required field: specversion'); + throw new Exception('Missing required field: specversion'); + } + + if (!is_string($array['specversion'])) { + throw new Exception('Attribute "specversion" must be a string'); + } + + $specversion = $array['specversion']; + + if ($specversion !== self::SPECVERSION && !($lenient && $allowUnknownSpecversion)) { + throw new Exception('Unsupported CloudEvents spec version: '.$specversion); } - if ($array['specversion'] !== '1.0') { - throw new InvalidArgumentException('Unsupported CloudEvents spec version: ' . $array['specversion']); + if (!isset($array['type'])) { + throw new Exception('Missing required field: type'); } - if (!isset($array['type']) || empty($array['type'])) { - throw new InvalidArgumentException('Missing required field: type'); + if (!is_string($array['type'])) { + throw new Exception('Attribute "type" must be a string'); + } + + if ($array['type'] === '') { + throw new Exception('Missing required field: type'); + } + + $extensions = []; + + foreach ($array as $name => $value) { + if (in_array((string) $name, self::RESERVED, true)) { + continue; + } + + $extensions[$name] = $value; } return new self( - specversion: $array['specversion'], + specversion: $specversion, type: $array['type'], - source: $array['source'], - subject: $array['subject'] ?? null, - id: $array['id'], - time: $array['time'], - datacontenttype: $array['datacontenttype'] ?? 'application/json', - data: $array['data'] ?? [] + source: self::readString($array, 'source', $lenient) ?? '', + subject: self::readString($array, 'subject', $lenient), + id: self::readString($array, 'id', $lenient) ?? '', + time: self::readString($array, 'time', $lenient) ?? '', + datacontenttype: self::readString($array, 'datacontenttype', $lenient) ?? 'application/json', + // Absent data defaults to an empty array, but an explicit null is a + // valid payload and is kept as-is so a round trip stays lossless. + data: array_key_exists('data', $array) ? $array['data'] : [], + dataschema: self::readString($array, 'dataschema', $lenient), + extensions: self::filterExtensions($extensions, $lenient), ); } /** * Convert CloudEvent to array * + * Every spec attribute is always present; a null value means unset, which the + * JSON format treats as equivalent to omitting the member. Extension attributes + * are emitted as top-level members alongside them. + * * @return array */ public function toArray(): array { - return [ + return array_merge([ 'specversion' => $this->specversion, 'type' => $this->type, 'source' => $this->source, @@ -82,26 +200,219 @@ public function toArray(): array 'id' => $this->id, 'time' => $this->time, 'datacontenttype' => $this->datacontenttype, - 'data' => $this->data - ]; + 'dataschema' => $this->dataschema, + 'data' => $this->data, + ], $this->extensions); } /** * Validate the CloudEvent * - * @return bool - * @throws InvalidArgumentException + * Enforces the four REQUIRED context attributes: `id`, `source`, `specversion` + * and `type`, where `id` must be a non-empty string and `source` a non-empty + * URI-reference. + * + * @throws Exception */ public function validate(): bool { - if ($this->specversion !== '1.0') { - throw new InvalidArgumentException('Unsupported CloudEvents spec version: ' . $this->specversion); + if ($this->specversion !== self::SPECVERSION) { + throw new Exception('Unsupported CloudEvents spec version: '.$this->specversion); } - if (empty($this->type)) { - throw new InvalidArgumentException('Event type is required'); + if ($this->type === '') { + throw new Exception('Event type is required'); + } + + if ($this->id === '') { + throw new Exception('Event id is required'); + } + + if ($this->source === '') { + throw new Exception('Event source is required'); } return true; } + + /** + * Get a single extension attribute + * + * @return string|int|float|bool|null The $default when the attribute is not set + */ + public function getExtension(string $name, string|int|float|bool|null $default = null): string|int|float|bool|null + { + return $this->extensions[$name] ?? $default; + } + + /** + * Get all extension attributes, keyed by attribute name + * + * @return array + */ + public function getExtensions(): array + { + return $this->extensions; + } + + /** + * Return a copy with the given id + */ + public function withId(string $id): self + { + return $this->with(id: $id); + } + + /** + * Return a copy with the given time + * + * @param string|null $time RFC 3339 timestamp, or null to stamp the current time + */ + public function withTime(?string $time = null): self + { + return $this->with(time: $time ?? self::now()); + } + + /** + * Return a copy with the given source + */ + public function withSource(string $source): self + { + return $this->with(source: $source); + } + + /** + * Return a copy with the given subject + */ + public function withSubject(?string $subject): self + { + return $this->with(subject: $subject, subjectSet: true); + } + + /** + * Return a copy with the given data payload + */ + public function withData(mixed $data): self + { + return $this->with(data: $data, dataSet: true); + } + + /** + * Return a copy with the given extension attribute set + * + * @param string $name Lowercase a-z and 0-9 only, and not a spec attribute name + * @param string|int|float|bool|null $value A null value unsets the attribute + * + * @throws Exception on an invalid extension attribute name or value + */ + public function withExtension(string $name, mixed $value): self + { + return $this->with(extensions: array_merge($this->extensions, [$name => $value])); + } + + /** + * Build a copy of this event, overriding the given attributes + * + * @param array|null $extensions + * @param bool $subjectSet Whether $subject was given, since null is a meaningful value + * @param bool $dataSet Whether $data was given, since null is a meaningful value + * + * @throws Exception + */ + private function with( + ?string $type = null, + ?string $source = null, + ?string $subject = null, + ?string $id = null, + ?string $time = null, + ?string $datacontenttype = null, + mixed $data = null, + ?array $extensions = null, + bool $subjectSet = false, + bool $dataSet = false, + ): self { + return new self( + specversion: $this->specversion, + type: $type ?? $this->type, + source: $source ?? $this->source, + subject: $subjectSet ? $subject : $this->subject, + id: $id ?? $this->id, + time: $time ?? $this->time, + datacontenttype: $datacontenttype ?? $this->datacontenttype, + data: $dataSet ? $data : $this->data, + dataschema: $this->dataschema, + extensions: $extensions ?? $this->extensions, + ); + } + + /** + * Read a string attribute, treating an explicit null as unset + * + * @param array $array + * @return string|null Null when the attribute is unset, or when it is malformed + * and $lenient is true + * + * @throws Exception when the attribute is malformed and $lenient is false + */ + private static function readString(array $array, string $name, bool $lenient): ?string + { + if (!isset($array[$name])) { + return null; + } + + if (!is_string($array[$name])) { + if ($lenient) { + return null; + } + + throw new Exception('Attribute "'.$name.'" must be a string'); + } + + return $array[$name]; + } + + /** + * Validate extension attribute names and values + * + * Names are restricted to lowercase a-z and 0-9 by the spec, and values must be + * of a CloudEvents type, all of which map to a JSON scalar. An attribute whose + * value is null is treated as unset. + * + * @param array $extensions + * @return array + * + * @throws Exception when an attribute is invalid and $lenient is false + */ + private static function filterExtensions(array $extensions, bool $lenient): array + { + $filtered = []; + + foreach ($extensions as $name => $value) { + $name = (string) $name; + + if ($value === null) { + continue; + } + + if (!preg_match('/^[a-z0-9]+$/', $name) || in_array($name, self::RESERVED, true)) { + if ($lenient) { + continue; + } + + throw new Exception('Invalid extension attribute name: '.$name); + } + + if (!is_string($value) && !is_int($value) && !is_float($value) && !is_bool($value)) { + if ($lenient) { + continue; + } + + throw new Exception('Invalid extension attribute value for "'.$name.'": must be a string, integer, float or boolean'); + } + + $filtered[$name] = $value; + } + + return $filtered; + } } diff --git a/src/CloudEvents/Exception.php b/src/CloudEvents/Exception.php new file mode 100644 index 0000000..1f2f5eb --- /dev/null +++ b/src/CloudEvents/Exception.php @@ -0,0 +1,16 @@ + 'event-abc', 'time' => '2025-11-07T10:00:00Z', 'datacontenttype' => 'application/json', + 'dataschema' => null, 'data' => ['orderId' => '789', 'amount' => 99.99] ], $array); } @@ -245,4 +247,468 @@ public function testRoundTrip(): void $this->assertEquals($original->datacontenttype, $restored->datacontenttype); $this->assertEquals($original->data, $restored->data); } + + /** + * Item 1: sparse but valid input must not crash. + */ + public function testFromArrayWithSparseInput(): void + { + $event = CloudEvent::fromArray(['specversion' => '1.0', 'type' => 'x']); + + $this->assertEquals('1.0', $event->specversion); + $this->assertEquals('x', $event->type); + $this->assertEquals('', $event->source); + $this->assertEquals('', $event->id); + $this->assertEquals('', $event->time); + $this->assertNull($event->subject); + $this->assertNull($event->dataschema); + $this->assertEquals('application/json', $event->datacontenttype); + $this->assertEquals([], $event->data); + } + + public function testFromArrayWithNullAttributes(): void + { + $event = CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'x', + 'source' => null, + 'id' => null, + 'time' => null, + 'subject' => null, + 'datacontenttype' => null, + 'dataschema' => null, + ]); + + $this->assertEquals('', $event->source); + $this->assertEquals('', $event->id); + $this->assertEquals('', $event->time); + $this->assertNull($event->subject); + $this->assertNull($event->dataschema); + $this->assertEquals('application/json', $event->datacontenttype); + } + + public function testFromArrayThrowsLibraryException(): void + { + try { + CloudEvent::fromArray(['specversion' => '1.0', 'type' => 'x', 'source' => 123]); + $this->fail('Expected a CloudEvents exception'); + } catch (CloudEventException $e) { + $this->assertEquals('Attribute "source" must be a string', $e->getMessage()); + $this->assertInstanceOf(InvalidArgumentException::class, $e); + } + } + + public function testFromArrayNonStringSpecversion(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Attribute "specversion" must be a string'); + + CloudEvent::fromArray(['specversion' => 1.0, 'type' => 'x']); + } + + public function testFromArrayNonStringType(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Attribute "type" must be a string'); + + CloudEvent::fromArray(['specversion' => '1.0', 'type' => ['x']]); + } + + /** + * Item 2: all four REQUIRED attributes are enforced. + */ + public function testValidateMissingId(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Event id is required'); + + (new CloudEvent(type: 'test.event', source: 'test-service'))->validate(); + } + + public function testValidateMissingSource(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Event source is required'); + + (new CloudEvent(type: 'test.event', id: 'test-id'))->validate(); + } + + public function testValidateRejectsEventDecodedFromSparseInput(): void + { + $event = CloudEvent::fromArray(['specversion' => '1.0', 'type' => 'x']); + + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Event id is required'); + + $event->validate(); + } + + public function testValidateAcceptsAllFourRequiredAttributes(): void + { + $event = new CloudEvent( + type: 'test.event', + source: '/services/test', + id: '0' + ); + + $this->assertTrue($event->validate()); + } + + /** + * Item 3: `data` is unrestricted. + * + * @return array + */ + public static function dataPayloadProvider(): array + { + return [ + 'object' => [['key' => 'value']], + 'list' => [['a', 'b', 'c']], + 'nested list' => [[['id' => 1], ['id' => 2]]], + 'string' => ['plain text'], + 'integer' => [42], + 'float' => [99.99], + 'boolean' => [true], + 'null' => [null], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataPayloadProvider')] + public function testDataPayloadRoundTrip(mixed $data): void + { + $original = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + time: '2025-11-07T10:00:00Z', + data: $data + ); + + $this->assertSame($data, $original->data); + + $restored = CloudEvent::fromArray($original->toArray()); + + $this->assertSame($data, $restored->data); + } + + public function testDataDefaultsToEmptyArrayWhenAbsent(): void + { + $event = CloudEvent::fromArray(['specversion' => '1.0', 'type' => 'x']); + + $this->assertSame([], $event->data); + } + + /** + * Item 4: extension attributes are carried. + */ + public function testFromArrayCarriesExtensions(): void + { + $event = CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + 'retrycount' => 3, + 'sampled' => true, + ]); + + $this->assertEquals([ + 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + 'retrycount' => 3, + 'sampled' => true, + ], $event->getExtensions()); + + $this->assertEquals('00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', $event->getExtension('traceparent')); + $this->assertNull($event->getExtension('missing')); + $this->assertEquals('fallback', $event->getExtension('missing', 'fallback')); + } + + public function testExtensionRoundTripIsLossless(): void + { + $original = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + time: '2025-11-07T10:00:00Z', + data: ['key' => 'value'], + extensions: ['traceparent' => '00-abc-def-01', 'retrycount' => 3] + ); + + $array = $original->toArray(); + + $this->assertEquals('00-abc-def-01', $array['traceparent']); + $this->assertEquals(3, $array['retrycount']); + + $restored = CloudEvent::fromArray($array); + + $this->assertEquals($original->getExtensions(), $restored->getExtensions()); + $this->assertEquals($array, $restored->toArray()); + } + + public function testExtensionNamesMustBeLowercaseAlphanumeric(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Invalid extension attribute name: traceParent'); + + new CloudEvent(extensions: ['traceParent' => 'x']); + } + + public function testExtensionNameMayNotCollideWithSpecAttribute(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Invalid extension attribute name: type'); + + new CloudEvent(extensions: ['type' => 'x']); + } + + public function testExtensionValueMustBeScalar(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Invalid extension attribute value for "trace"'); + + new CloudEvent(extensions: ['trace' => ['nested' => 'value']]); + } + + public function testNullExtensionValueIsTreatedAsUnset(): void + { + $event = new CloudEvent(extensions: ['traceparent' => null]); + + $this->assertEquals([], $event->getExtensions()); + } + + /** + * Item 5: the OPTIONAL `dataschema` attribute. + */ + 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']); + + $restored = CloudEvent::fromArray($event->toArray()); + + $this->assertEquals('https://example.com/schemas/user.json', $restored->dataschema); + $this->assertEquals([], $restored->getExtensions()); + } + + /** + * Item 6: withers. + */ + public function testWithers(): void + { + $event = new CloudEvent(type: 'test.event', source: 'test-service'); + + $staged = $event + ->withId('event-123') + ->withTime('2025-11-07T10:00:00Z') + ->withSource('/services/test') + ->withSubject('user-1') + ->withData(['key' => 'value']) + ->withExtension('traceparent', '00-abc-def-01'); + + $this->assertEquals('event-123', $staged->id); + $this->assertEquals('2025-11-07T10:00:00Z', $staged->time); + $this->assertEquals('/services/test', $staged->source); + $this->assertEquals('user-1', $staged->subject); + $this->assertEquals(['key' => 'value'], $staged->data); + $this->assertEquals('00-abc-def-01', $staged->getExtension('traceparent')); + $this->assertEquals('test.event', $staged->type); + + // The original is untouched + $this->assertNotSame($event, $staged); + $this->assertEquals('', $event->id); + $this->assertEquals('', $event->time); + $this->assertEquals('test-service', $event->source); + $this->assertNull($event->subject); + $this->assertEquals([], $event->data); + $this->assertEquals([], $event->getExtensions()); + } + + public function testWithersAcceptNullValues(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + subject: 'user-1', + id: 'test-id', + data: ['key' => 'value'] + ); + + $this->assertNull($event->withSubject(null)->subject); + $this->assertNull($event->withData(null)->data); + } + + public function testWithTimeStampsTheCurrentTimeByDefault(): void + { + $event = (new CloudEvent(type: 'test.event', source: 'test-service'))->withTime(); + + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', $event->time); + } + + public function testWithExtensionRejectsInvalidName(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Invalid extension attribute name: trace_parent'); + + (new CloudEvent())->withExtension('trace_parent', 'x'); + } + + public function testWithExtensionKeepsExistingExtensions(): void + { + $event = (new CloudEvent(extensions: ['traceparent' => '00-abc-def-01'])) + ->withExtension('retrycount', 2); + + $this->assertEquals([ + 'traceparent' => '00-abc-def-01', + 'retrycount' => 2, + ], $event->getExtensions()); + } + + /** + * Item 7: RFC 3339 timestamp helper. + */ + public function testNow(): void + { + $now = CloudEvent::now(); + + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', $now); + $this->assertInstanceOf(\DateTimeImmutable::class, \DateTimeImmutable::createFromFormat(CloudEvent::TIME_FORMAT, $now)); + + $parsed = new \DateTimeImmutable($now); + + $this->assertEquals(0, $parsed->getOffset()); + $this->assertEqualsWithDelta(time(), $parsed->getTimestamp(), 5); + } + + /** + * Item 8: strict versus lenient decoding. + */ + public function testStrictDecodeRejectsMalformedOptionalAttribute(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Attribute "subject" must be a string'); + + CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'subject' => ['not', 'a', 'string'], + ]); + } + + public function testLenientDecodeCoercesMalformedOptionalAttribute(): void + { + $event = CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'subject' => ['not', 'a', 'string'], + 'time' => 12345, + 'data' => ['key' => 'value'], + ], lenient: true); + + $this->assertNull($event->subject); + $this->assertEquals('', $event->time); + $this->assertEquals('test-id', $event->id); + $this->assertEquals(['key' => 'value'], $event->data); + $this->assertTrue($event->validate()); + } + + public function testLenientDecodeDropsInvalidExtensions(): void + { + $event = CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'traceparent' => '00-abc-def-01', + 'traceParent' => 'invalid name', + 'nested' => ['not' => 'scalar'], + ], lenient: true); + + $this->assertEquals(['traceparent' => '00-abc-def-01'], $event->getExtensions()); + } + + public function testStrictDecodeRejectsInvalidExtensions(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Invalid extension attribute name: traceParent'); + + CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'traceParent' => 'invalid name', + ]); + } + + public function testLenientDecodeStillRejectsUnknownSpecversionByDefault(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Unsupported CloudEvents spec version: 1.1'); + + CloudEvent::fromArray([ + 'specversion' => '1.1', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + ], lenient: true); + } + + public function testLenientDecodeCanAcceptUnknownSpecversion(): void + { + $event = CloudEvent::fromArray([ + 'specversion' => '1.1', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + ], lenient: true, allowUnknownSpecversion: true); + + $this->assertEquals('1.1', $event->specversion); + + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Unsupported CloudEvents spec version: 1.1'); + + $event->validate(); + } + + public function testStrictDecodeRejectsUnknownSpecversionEvenWhenAllowed(): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Unsupported CloudEvents spec version: 1.1'); + + CloudEvent::fromArray([ + 'specversion' => '1.1', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + ], allowUnknownSpecversion: true); + } + + public function testLenientDecodeStillRequiresSpecversionAndType(): void + { + try { + CloudEvent::fromArray(['type' => 'test.event'], lenient: true); + $this->fail('Expected a CloudEvents exception'); + } catch (CloudEventException $e) { + $this->assertEquals('Missing required field: specversion', $e->getMessage()); + } + + try { + CloudEvent::fromArray(['specversion' => '1.0'], lenient: true); + $this->fail('Expected a CloudEvents exception'); + } catch (CloudEventException $e) { + $this->assertEquals('Missing required field: type', $e->getMessage()); + } + } } From 0c3bea2ca6f0e0c8e73f41bc724bc5d56b49402c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Jul 2026 15:46:35 +0200 Subject: [PATCH 2/3] Address review feedback on extension names, source and extension types - toArray() uses the union operator instead of array_merge(), which renumbered a digits-only extension name such as "123" that PHP had cast to an int key, so it was emitted as "0" and could not round trip. - validate() now checks that source is a syntactically valid RFC 3986 URI-reference, rejecting unescaped spaces, control characters, raw non-ASCII and malformed percent-escapes. - Extension values are restricted to string, integer and boolean. The CloudEvents type system has no floating-point type, and its Binary, URI and Timestamp types all serialize as strings. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 ++- src/CloudEvents/CloudEvent.php | 59 ++++++++++++----- tests/CloudEvents/CloudEventTest.php | 98 +++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index adb95d0..4ebea6c 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,9 @@ and `source` — call `validate()` for a full conformance check. ### Validating a CloudEvent `validate()` enforces the four REQUIRED context attributes: `id`, `source`, -`specversion` and `type`. +`specversion` and `type`. `source` must also be a syntactically valid +URI-reference, so a relative reference such as `user-service` or `/services/db` +passes, while one containing an unescaped space does not. ```php use Utopia\CloudEvents\Exception as CloudEventException; @@ -167,7 +169,9 @@ The `CloudEvent` class supports the following properties according to the CloudE An event may carry any number of extension context attributes, such as `traceparent`. Names are restricted by the spec to lowercase `a-z` and `0-9`, and -values must be scalar; anything else raises in strict mode. +values must be a string, integer or boolean — the CloudEvents type system has no +floating-point type, and its Binary, URI and Timestamp types all serialize as +strings. Anything else raises in strict mode, and is dropped in lenient mode. ```php $event = $event->withExtension('traceparent', $trace); diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index efbb3be..96eee37 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -48,7 +48,10 @@ class CloudEvent /** * Extension context attributes, keyed by attribute name. * - * @var array + * A digits-only name such as "123" is legal, and PHP stores it as an int key, + * which is why the key type here is array-key rather than string. + * + * @var array */ public readonly array $extensions; @@ -107,7 +110,8 @@ public static function now(): string * - `specversion` is missing, or is not the string "1.0"; * - `type` is missing, empty, or not a string; * - any other spec attribute is present with a non-string value; - * - an extension attribute has an invalid name or a non-scalar value. + * - an extension attribute has an invalid name, or a value that is not a + * string, integer or boolean. * * Lenient mode ($lenient = true) raises an Exception only for the `specversion` * and `type` failures above; it never invents a required attribute. Every other @@ -188,11 +192,13 @@ public static function fromArray(array $array, bool $lenient = false, bool $allo * JSON format treats as equivalent to omitting the member. Extension attributes * are emitted as top-level members alongside them. * - * @return array + * @return array */ public function toArray(): array { - return array_merge([ + // The union operator rather than array_merge(), which would renumber a + // digits-only extension name such as "123" that PHP has cast to an int key. + return [ 'specversion' => $this->specversion, 'type' => $this->type, 'source' => $this->source, @@ -202,7 +208,7 @@ public function toArray(): array 'datacontenttype' => $this->datacontenttype, 'dataschema' => $this->dataschema, 'data' => $this->data, - ], $this->extensions); + ] + $this->extensions; } /** @@ -232,15 +238,19 @@ public function validate(): bool throw new Exception('Event source is required'); } + if (!self::isUriReference($this->source)) { + throw new Exception('Event source must be a valid URI-reference'); + } + return true; } /** * Get a single extension attribute * - * @return string|int|float|bool|null The $default when the attribute is not set + * @return string|int|bool|null The $default when the attribute is not set */ - public function getExtension(string $name, string|int|float|bool|null $default = null): string|int|float|bool|null + public function getExtension(string $name, string|int|bool|null $default = null): string|int|bool|null { return $this->extensions[$name] ?? $default; } @@ -248,7 +258,7 @@ public function getExtension(string $name, string|int|float|bool|null $default = /** * Get all extension attributes, keyed by attribute name * - * @return array + * @return array */ public function getExtensions(): array { @@ -301,7 +311,7 @@ public function withData(mixed $data): self * Return a copy with the given extension attribute set * * @param string $name Lowercase a-z and 0-9 only, and not a spec attribute name - * @param string|int|float|bool|null $value A null value unsets the attribute + * @param string|int|bool|null $value A null value unsets the attribute * * @throws Exception on an invalid extension attribute name or value */ @@ -374,12 +384,14 @@ private static function readString(array $array, string $name, bool $lenient): ? /** * Validate extension attribute names and values * - * Names are restricted to lowercase a-z and 0-9 by the spec, and values must be - * of a CloudEvents type, all of which map to a JSON scalar. An attribute whose - * value is null is treated as unset. + * Names are restricted to lowercase a-z and 0-9 by the spec. Values must be of a + * CloudEvents type; the type system has no floating-point type, and Binary, URI, + * URI-reference and Timestamp all serialize as strings, so what remains in JSON + * is a string, an integer or a boolean. An attribute whose value is null is + * treated as unset. * * @param array $extensions - * @return array + * @return array * * @throws Exception when an attribute is invalid and $lenient is false */ @@ -402,12 +414,12 @@ private static function filterExtensions(array $extensions, bool $lenient): arra throw new Exception('Invalid extension attribute name: '.$name); } - if (!is_string($value) && !is_int($value) && !is_float($value) && !is_bool($value)) { + if (!is_string($value) && !is_int($value) && !is_bool($value)) { if ($lenient) { continue; } - throw new Exception('Invalid extension attribute value for "'.$name.'": must be a string, integer, float or boolean'); + throw new Exception('Invalid extension attribute value for "'.$name.'": must be a string, integer or boolean'); } $filtered[$name] = $value; @@ -415,4 +427,21 @@ private static function filterExtensions(array $extensions, bool $lenient): arra return $filtered; } + + /** + * Check whether a string is a syntactically valid RFC 3986 URI-reference + * + * A URI-reference is either a URI or a relative reference, so "/services/db" and + * "user-service" are both fine. What it may not contain is a character outside + * the unreserved and reserved sets — a space, a control character or a raw + * non-ASCII byte must be percent-encoded — or a malformed percent-escape. + */ + private static function isUriReference(string $value): bool + { + if (preg_match('/^[A-Za-z0-9\-._~:\/?#\[\]@!$&\'()*+,;=%]*$/', $value) !== 1) { + return false; + } + + return preg_match('/%(?![0-9A-Fa-f]{2})/', $value) === 0; + } } diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 3456609..6f892f0 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -343,6 +343,53 @@ public function testValidateRejectsEventDecodedFromSparseInput(): void $event->validate(); } + /** + * @return array + */ + public static function validSourceProvider(): array + { + return [ + 'relative path' => ['/services/db'], + 'relative reference' => ['user-service'], + 'absolute uri' => ['https://github.com/cloudevents/spec/pull/123'], + 'urn' => ['urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66'], + 'percent encoded' => ['/services/my%20service'], + 'query and fragment' => ['/services/db?tenant=1#events'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('validSourceProvider')] + public function testValidateAcceptsUriReferenceSource(string $source): void + { + $event = new CloudEvent(type: 'test.event', source: $source, id: 'test-id'); + + $this->assertTrue($event->validate()); + } + + /** + * @return array + */ + public static function invalidSourceProvider(): array + { + return [ + 'unescaped space' => ['my service'], + 'control character' => ["test\nservice"], + 'raw non-ascii' => ['/services/café'], + 'truncated percent escape' => ['/services/my%2'], + 'invalid percent escape' => ['/services/my%zz'], + 'angle brackets' => [''], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidSourceProvider')] + public function testValidateRejectsMalformedSource(string $source): void + { + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Event source must be a valid URI-reference'); + + (new CloudEvent(type: 'test.event', source: $source, id: 'test-id'))->validate(); + } + public function testValidateAcceptsAllFourRequiredAttributes(): void { $event = new CloudEvent( @@ -462,14 +509,61 @@ public function testExtensionNameMayNotCollideWithSpecAttribute(): void new CloudEvent(extensions: ['type' => 'x']); } - public function testExtensionValueMustBeScalar(): void + public function testExtensionValueMustBeStringIntegerOrBoolean(): void { $this->expectException(CloudEventException::class); - $this->expectExceptionMessage('Invalid extension attribute value for "trace"'); + $this->expectExceptionMessage('Invalid extension attribute value for "trace": must be a string, integer or boolean'); new CloudEvent(extensions: ['trace' => ['nested' => 'value']]); } + public function testFloatExtensionValueIsRejected(): void + { + // The CloudEvents type system has no floating-point type + $this->expectException(CloudEventException::class); + $this->expectExceptionMessage('Invalid extension attribute value for "sampling": must be a string, integer or boolean'); + + new CloudEvent(extensions: ['sampling' => 0.5]); + } + + public function testFloatExtensionValueIsDroppedWhenLenient(): void + { + $event = CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'sampling' => 0.5, + 'traceparent' => '00-abc-def-01', + ], lenient: true); + + $this->assertEquals(['traceparent' => '00-abc-def-01'], $event->getExtensions()); + } + + public function testNumericExtensionNameRoundTripsLosslessly(): void + { + // PHP casts a digits-only key to an int, which array_merge() would renumber + $original = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + extensions: ['123' => 'x', 'traceparent' => '00-abc-def-01'] + ); + + $array = $original->toArray(); + + $this->assertArrayHasKey('123', $array); + $this->assertEquals('x', $array['123'] ?? null); + $this->assertArrayNotHasKey(0, $array); + $this->assertStringContainsString('"123":"x"', (string) json_encode($array)); + + $restored = CloudEvent::fromArray($array); + + $this->assertEquals($original->getExtensions(), $restored->getExtensions()); + $this->assertEquals('x', $restored->getExtension('123')); + $this->assertEquals($array, $restored->toArray()); + } + public function testNullExtensionValueIsTreatedAsUnset(): void { $event = new CloudEvent(extensions: ['traceparent' => null]); From 68cd50590b94e250970f226a0067caf471adf42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Jul 2026 15:53:17 +0200 Subject: [PATCH 3/3] Validate the full URI-reference grammar and fix withExtension() key handling - withExtension() uses array_replace() instead of array_merge(), which renumbered a digits-only extension name PHP had cast to an int key, so withExtension('123', 'x') stored the value under 0. - isUriReference() now follows the RFC 3986 appendix A grammar rather than only screening characters, so a structurally malformed authority such as http://[invalid] is rejected. IP-literal contents are validated as IPv6 via filter_var() or as an IPvFuture literal. Every repetition in the pattern is possessive, so an untrusted source cannot trigger runaway backtracking. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 7 +-- src/CloudEvents/CloudEvent.php | 79 +++++++++++++++++++++++++--- tests/CloudEvents/CloudEventTest.php | 35 ++++++++++++ 3 files changed, 111 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4ebea6c..9ceaaf3 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,10 @@ and `source` — call `validate()` for a full conformance check. ### Validating a CloudEvent `validate()` enforces the four REQUIRED context attributes: `id`, `source`, -`specversion` and `type`. `source` must also be a syntactically valid -URI-reference, so a relative reference such as `user-service` or `/services/db` -passes, while one containing an unescaped space does not. +`specversion` and `type`. `source` is additionally checked against the RFC 3986 +URI-reference grammar, so a relative reference such as `user-service` or +`/services/db` passes, while `my service` (unescaped space) and +`http://[invalid]` (malformed authority) do not. ```php use Utopia\CloudEvents\Exception as CloudEventException; diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index 96eee37..c7ad0e0 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -317,7 +317,9 @@ public function withData(mixed $data): self */ public function withExtension(string $name, mixed $value): self { - return $this->with(extensions: array_merge($this->extensions, [$name => $value])); + // array_replace() rather than array_merge(), which would renumber a + // digits-only name that PHP has cast to an int key + return $this->with(extensions: array_replace($this->extensions, [$name => $value])); } /** @@ -431,17 +433,80 @@ private static function filterExtensions(array $extensions, bool $lenient): arra /** * Check whether a string is a syntactically valid RFC 3986 URI-reference * - * A URI-reference is either a URI or a relative reference, so "/services/db" and - * "user-service" are both fine. What it may not contain is a character outside - * the unreserved and reserved sets — a space, a control character or a raw - * non-ASCII byte must be percent-encoded — or a malformed percent-escape. + * A URI-reference is either a URI or a relative reference, so "/services/db" + * and "user-service" are both fine, while "my service" and "http://[invalid]" + * are not. The check follows the RFC 3986 grammar rather than only screening + * characters, so a structurally malformed authority is rejected too. + * + * @see https://www.rfc-editor.org/rfc/rfc3986#appendix-A */ private static function isUriReference(string $value): bool { - if (preg_match('/^[A-Za-z0-9\-._~:\/?#\[\]@!$&\'()*+,;=%]*$/', $value) !== 1) { + if (preg_match(self::uriReferencePattern(), $value) !== 1) { return false; } - return preg_match('/%(?![0-9A-Fa-f]{2})/', $value) === 0; + // Square brackets are only legal as the delimiters of an IP-literal host, + // so anything the grammar matched between them must be an IPv6 address or + // an IPvFuture literal. + if (preg_match_all('/\[([^\]]*)\]/', $value, $matches) === 0) { + return true; + } + + foreach ($matches[1] as $literal) { + $isIpV6 = filter_var($literal, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $isIpVFuture = preg_match('/\Av[0-9A-Fa-f]++\.[A-Za-z0-9\-._~!$&\'()*+,;=:]++\z/', $literal) === 1; + + if (!$isIpV6 && !$isIpVFuture) { + return false; + } + } + + return true; + } + + /** + * Build the RFC 3986 URI-reference pattern + * + * Composed from the ABNF in appendix A, with the IP-literal left loose because + * isUriReference() validates its contents separately. Every repetition is + * possessive: the character sets of adjacent rules are disjoint, so no + * backtracking is ever useful, and a hostile `source` cannot make this pattern + * blow up. + */ + private static function uriReferencePattern(): string + { + $unreserved = 'A-Za-z0-9\-._~'; + $subDelims = '!$&\'()*+,;='; + $pctEncoded = '%[0-9A-Fa-f]{2}'; + + $pchar = "(?:[{$unreserved}{$subDelims}:@]|{$pctEncoded})"; + $segment = "{$pchar}*+"; + $segmentNz = "{$pchar}++"; + // The first segment of a path-noscheme may not contain a colon, which would + // otherwise read as a scheme delimiter + $segmentNzNc = "(?:[{$unreserved}{$subDelims}@]|{$pctEncoded})++"; + + $scheme = '[A-Za-z][A-Za-z0-9+\-.]*+'; + $userinfo = "(?:[{$unreserved}{$subDelims}:]|{$pctEncoded})*+"; + $host = "(?:\[[^\]]*+\]|(?:[{$unreserved}{$subDelims}]|{$pctEncoded})*+)"; + $authority = "(?:{$userinfo}@)?{$host}(?::[0-9]*+)?"; + + $pathAbempty = "(?:\/{$segment})*+"; + $pathAbsolute = "\/(?:{$segmentNz}(?:\/{$segment})*+)?"; + $pathRootless = "{$segmentNz}(?:\/{$segment})*+"; + $pathNoscheme = "{$segmentNzNc}(?:\/{$segment})*+"; + + $hierPart = "(?:\/\/{$authority}{$pathAbempty}|{$pathAbsolute}|{$pathRootless}|)"; + $relativePart = "(?:\/\/{$authority}{$pathAbempty}|{$pathAbsolute}|{$pathNoscheme}|)"; + + $queryOrFragment = "(?:{$pchar}|[\/?])*+"; + $suffix = "(?:\?{$queryOrFragment})?(?:#{$queryOrFragment})?"; + + $uri = "{$scheme}:{$hierPart}{$suffix}"; + $relativeRef = "{$relativePart}{$suffix}"; + + // \A and \z rather than ^ and $, which would let a trailing newline through + return "/\A(?:{$uri}|{$relativeRef})\z/"; } } diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 6f892f0..3910534 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -355,6 +355,12 @@ public static function validSourceProvider(): array 'urn' => ['urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66'], 'percent encoded' => ['/services/my%20service'], 'query and fragment' => ['/services/db?tenant=1#events'], + 'network path reference' => ['//example.com/path'], + 'userinfo and port' => ['http://user:pw@example.com:8080/a/b?q=1#f'], + 'ipv4 host' => ['http://192.168.0.1/events'], + 'ipv6 literal' => ['http://[2001:db8::1]:8080/events'], + 'ipvfuture literal' => ['http://[v7.fe80::a+en1]/events'], + 'mailto' => ['mailto:events@example.com'], ]; } @@ -378,6 +384,12 @@ public static function invalidSourceProvider(): array 'truncated percent escape' => ['/services/my%2'], 'invalid percent escape' => ['/services/my%zz'], 'angle brackets' => [''], + 'trailing newline' => ["test-service\n"], + // Structurally malformed, even though every character is allowed + 'malformed ip literal' => ['http://[invalid]'], + 'unterminated ip literal' => ['http://[fe80::1'], + 'ipv4 in brackets' => ['http://[192.168.0.1]'], + 'brackets outside authority' => ['/services/[db]'], ]; } @@ -654,6 +666,29 @@ public function testWithExtensionRejectsInvalidName(): void (new CloudEvent())->withExtension('trace_parent', 'x'); } + public function testWithExtensionPreservesNumericName(): void + { + $event = (new CloudEvent(type: 'test.event', source: 'test-service', id: 'test-id')) + ->withExtension('123', 'x') + ->withExtension('traceparent', '00-abc-def-01'); + + $this->assertEquals(['123' => 'x', 'traceparent' => '00-abc-def-01'], $event->getExtensions()); + $this->assertEquals('x', $event->getExtension('123')); + + $array = $event->toArray(); + + $this->assertArrayNotHasKey(0, $array); + $this->assertStringContainsString('"123":"x"', (string) json_encode($array)); + $this->assertEquals($event->getExtensions(), CloudEvent::fromArray($array)->getExtensions()); + } + + public function testWithExtensionOverwritesExistingValue(): void + { + $event = (new CloudEvent(extensions: ['retrycount' => 1]))->withExtension('retrycount', 2); + + $this->assertEquals(['retrycount' => 2], $event->getExtensions()); + } + public function testWithExtensionKeepsExistingExtensions(): void { $event = (new CloudEvent(extensions: ['traceparent' => '00-abc-def-01']))