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 diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index 4def5c0..0250d56 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 @@ -10,6 +11,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 * @@ -19,8 +36,10 @@ 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 + * @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) */ public function __construct( public readonly string $type, @@ -29,8 +48,10 @@ 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, + public readonly ?string $dataschema = null, + public readonly array $extensions = [] ) { } @@ -53,6 +74,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'], @@ -60,28 +96,129 @@ 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, + dataschema: $array['dataschema'] ?? null, + extensions: $extensions ); } /** * 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->dataschema !== null) { + $array['dataschema'] = $this->dataschema; + } + + if ($this->data !== null) { + $array['data'] = $this->data; + } + + return $array + $this->extensions; + } + + /** + * Create CloudEvent from its JSON event format representation + * + * Binary payloads carried in the data_base64 member are decoded + * 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 + * + * @param string $json + * @return self + * @throws InvalidArgumentException + */ + public static function fromJson(string $json): self + { + try { + $raw = \json_decode($json, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new InvalidArgumentException('Invalid CloudEvent JSON: ' . $e->getMessage(), 0, $e); + } + + 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'); + } + + 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); + } } /** @@ -116,6 +253,40 @@ public function validate(): bool throw new InvalidArgumentException('Event time must not be empty when present'); } + if ($this->datacontenttype !== null && \trim($this->datacontenttype) === '') { + 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'); + } + + 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 63bf093..1cde457 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,122 @@ 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 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 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([ + '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 @@ -328,6 +431,300 @@ public function testValidateWithoutTime(): void $this->assertTrue($event->validate()); } + public function testExtensions(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + extensions: [ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + 'sequence' => 42, + '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()); + } + + public function testExtensionsDefaultToEmpty(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id' + ); + + $this->assertEquals([], $event->extensions); + } + + public function testToArrayIncludesExtensions(): void + { + $event = new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + extensions: ['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->extensions['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 testValidateRejectsInvalidExtensionName(): void + { + $event = 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 + { + $event = 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 + { + $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', + extensions: ['traceparent' => '00-abc-def-01'] + ); + + $restored = CloudEvent::fromArray($original->toArray()); + + $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((object) ['userId' => '123'], $event->data); + $this->assertEquals('00-abc-def-01', $event->extensions['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); + $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'], + extensions: ['traceparent' => '00-abc-def-01'] + ); + + $restored = CloudEvent::fromJson($original->toJson()); + + $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 { $original = new CloudEvent(