diff --git a/src/CloudEvents/CloudEvent.php b/src/CloudEvents/CloudEvent.php index acac892..dd14001 100644 --- a/src/CloudEvents/CloudEvent.php +++ b/src/CloudEvents/CloudEvent.php @@ -49,7 +49,7 @@ class CloudEvent * @param string|null $datacontenttype Content type of data (RFC 2046, default: "application/json"); pass null to leave it unset * @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) + * @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( @@ -65,10 +65,10 @@ public function __construct( public readonly array $extensions = [] ) { foreach ($this->extensions as $name => $value) { - self::assertValidExtensionName((string) $name); + $error = self::extensionError((string) $name, $value); - if (!\is_bool($value) && !\is_int($value) && !\is_string($value)) { - throw new InvalidArgumentException('Extension attribute "' . $name . '" must be a boolean, integer or string'); + if ($error !== null) { + throw new InvalidArgumentException($error); } } } @@ -94,39 +94,38 @@ public static function now(): string * absent (per the JSON format, absent datacontenttype already * implies a JSON payload). * - * @param array $array + * @param array $array + * @param bool $lenient Coerce malformed optional attributes to their default and drop invalid extension attributes instead of throwing + * @param bool $allowUnknownSpecversion Accept a spec version other than "1.0", which validate() will still reject (lenient mode only) * @return self * @throws InvalidArgumentException */ - public static function fromArray(array $array): self + public static function fromArray(array $array, bool $lenient = false, bool $allowUnknownSpecversion = false): self { - foreach (['specversion', 'type', 'source', 'id'] as $field) { - if (!isset($array[$field]) || !\is_string($array[$field]) || $array[$field] === '') { - throw new InvalidArgumentException('Missing required field: ' . $field); - } - } + $specversion = self::readRequiredString($array, 'specversion'); + $type = self::readRequiredString($array, 'type'); + $source = self::readRequiredString($array, 'source'); + $id = self::readRequiredString($array, 'id'); - if ($array['specversion'] !== '1.0') { - throw new InvalidArgumentException('Unsupported CloudEvents spec version: ' . $array['specversion']); + if ($specversion !== '1.0' && !($lenient && $allowUnknownSpecversion)) { + throw new InvalidArgumentException('Unsupported CloudEvents spec version: ' . $specversion); } - // Null values mean the attribute is unset; the constructor - // validates whatever remains. - $extensions = \array_filter( + $extensions = self::filterExtensions( \array_diff_key($array, \array_flip(self::RESERVED_ATTRIBUTES)), - fn (mixed $value): bool => $value !== null + $lenient ); return new self( - type: $array['type'], - source: $array['source'], - id: $array['id'], - specversion: $array['specversion'], - subject: $array['subject'] ?? null, - time: $array['time'] ?? null, - datacontenttype: $array['datacontenttype'] ?? null, + type: $type, + source: $source, + id: $id, + specversion: $specversion, + subject: self::readString($array, 'subject', $lenient), + time: self::readString($array, 'time', $lenient), + datacontenttype: self::readString($array, 'datacontenttype', $lenient), data: $array['data'] ?? null, - dataschema: $array['dataschema'] ?? null, + dataschema: self::readString($array, 'dataschema', $lenient), extensions: $extensions ); } @@ -137,7 +136,7 @@ public static function fromArray(array $array): self * Optional attributes that are absent are omitted, since the spec * does not allow null attribute values. * - * @return array + * @return array */ public function toArray(): array { @@ -182,10 +181,12 @@ public function toArray(): array * @see https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md * * @param string $json + * @param bool $lenient See fromArray() + * @param bool $allowUnknownSpecversion See fromArray() * @return self * @throws InvalidArgumentException */ - public static function fromJson(string $json): self + public static function fromJson(string $json, bool $lenient = false, bool $allowUnknownSpecversion = false): self { try { $raw = \json_decode($json, false, 512, JSON_THROW_ON_ERROR); @@ -218,7 +219,7 @@ public static function fromJson(string $json): self $decoded['data'] = $binary; } - return self::fromArray($decoded); + return self::fromArray($decoded, $lenient, $allowUnknownSpecversion); } /** @@ -296,20 +297,103 @@ public function validate(): bool } /** - * Assert that a name is a valid, non-reserved extension attribute name + * Read a REQUIRED string attribute * + * @param array $array * @param string $name - * @return void - * @throws InvalidArgumentException + * @return string + * @throws InvalidArgumentException When the attribute is absent, empty or not a string */ - private static function assertValidExtensionName(string $name): void + private static function readRequiredString(array $array, string $name): string + { + if (!isset($array[$name]) || $array[$name] === '') { + throw new InvalidArgumentException('Missing required field: ' . $name); + } + + if (!\is_string($array[$name])) { + throw new InvalidArgumentException('Attribute "' . $name . '" must be a string'); + } + + return $array[$name]; + } + + /** + * Read an OPTIONAL string attribute, treating an explicit null as unset + * + * @param array $array + * @param string $name + * @param bool $lenient + * @return string|null Null when the attribute is unset, or when it is + * malformed and $lenient is true + * @throws InvalidArgumentException 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 InvalidArgumentException('Attribute "' . $name . '" must be a string'); + } + + return $array[$name]; + } + + /** + * Drop unset extension attributes, and in lenient mode invalid ones too + * + * Null values mean the attribute is unset. Anything else is passed + * through in strict mode for the constructor to validate. + * + * @param array $extensions + * @param bool $lenient + * @return array + */ + private static function filterExtensions(array $extensions, bool $lenient): array + { + $filtered = []; + + foreach ($extensions as $name => $value) { + if ($value === null) { + continue; + } + + if ($lenient && self::extensionError((string) $name, $value) !== null) { + continue; + } + + $filtered[$name] = $value; + } + + return $filtered; + } + + /** + * Describe why an extension attribute is invalid + * + * @param string $name + * @param mixed $value + * @return string|null The error message, or null when the attribute is valid + */ + private static function extensionError(string $name, mixed $value): ?string { if (!\preg_match('/^[a-z0-9]+$/', $name)) { - throw new InvalidArgumentException('Extension attribute name must contain only lowercase letters and digits: ' . $name); + return '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); + return 'Extension attribute name conflicts with a core attribute: ' . $name; } + + if (!\is_bool($value) && !\is_int($value) && !\is_string($value)) { + return 'Extension attribute "' . $name . '" must be a boolean, integer or string'; + } + + return null; } } diff --git a/tests/CloudEvents/CloudEventTest.php b/tests/CloudEvents/CloudEventTest.php index 2cc0cd9..cbe0caa 100644 --- a/tests/CloudEvents/CloudEventTest.php +++ b/tests/CloudEvents/CloudEventTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\CloudEvents; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Utopia\CloudEvents\CloudEvent; @@ -288,6 +289,7 @@ public function testDataschema(): void $restored = CloudEvent::fromArray($event->toArray()); $this->assertEquals($event->dataschema, $restored->dataschema); + $this->assertEquals([], $restored->extensions); } public function testDataschemaAbsent(): void @@ -449,6 +451,12 @@ public function testNow(): void $time = CloudEvent::now(); $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', $time); + $this->assertInstanceOf(\DateTimeImmutable::class, \DateTimeImmutable::createFromFormat(CloudEvent::TIME_FORMAT, $time)); + + $parsed = new \DateTimeImmutable($time); + + $this->assertEquals(0, $parsed->getOffset()); + $this->assertEqualsWithDelta(time(), $parsed->getTimestamp(), 5); } public function testNowIsValidEventTime(): void @@ -776,4 +784,306 @@ public function testRoundTrip(): void $this->assertEquals($original->datacontenttype, $restored->datacontenttype); $this->assertEquals($original->data, $restored->data); } + + public function testFromArrayNonStringSource(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Attribute "source" must be a string'); + + CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 123, + 'id' => 'test-id' + ]); + } + + public function testFromArrayNonStringSpecversion(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Attribute "specversion" must be a string'); + + CloudEvent::fromArray([ + 'specversion' => 1.0, + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id' + ]); + } + + public function testFromArrayNonStringType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Attribute "type" must be a string'); + + CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => ['test.event'], + 'source' => 'test-service', + 'id' => 'test-id' + ]); + } + + public function testFromArrayTreatsNullOptionalAttributesAsUnset(): void + { + $event = CloudEvent::fromArray([ + 'specversion' => '1.0', + 'type' => 'test.event', + 'source' => 'test-service', + 'id' => 'test-id', + 'subject' => null, + 'time' => null, + 'datacontenttype' => null, + 'dataschema' => null + ]); + + $this->assertNull($event->subject); + $this->assertNull($event->time); + $this->assertNull($event->datacontenttype); + $this->assertNull($event->dataschema); + $this->assertTrue($event->validate()); + } + + public function testValidateAcceptsAllFourRequiredAttributes(): void + { + $event = new CloudEvent( + type: 'test.event', + source: '/services/test', + id: '0' + ); + + $this->assertTrue($event->validate()); + } + + /** + * @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], + ]; + } + + #[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 testFloatExtensionValueIsRejected(): void + { + // The CloudEvents type system has no floating-point type + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Extension attribute "sampling" must be a boolean, integer or string'); + + new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + extensions: ['sampling' => 0.5] + ); + } + + public function testConstructorRejectsNullExtensionValue(): void + { + // fromArray() reads a null as an unset attribute, since that is what the + // JSON format says; an explicit null passed in code is a mistake + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Extension attribute "traceparent" must be a boolean, integer or string'); + + new CloudEvent( + type: 'test.event', + source: 'test-service', + id: 'test-id', + extensions: ['traceparent' => null] + ); + } + + 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->extensions, $restored->extensions); + $this->assertEquals($array, $restored->toArray()); + } + + 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->extensions, $restored->extensions); + $this->assertEquals($array, $restored->toArray()); + } + + public function testStrictDecodeRejectsMalformedOptionalAttribute(): void + { + $this->expectException(InvalidArgumentException::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->assertNull($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', + 'sampling' => 0.5, + 'nested' => ['not' => 'scalar'] + ], lenient: true); + + $this->assertEquals(['traceparent' => '00-abc-def-01'], $event->extensions); + } + + public function testLenientDecodeStillRejectsUnknownSpecversionByDefault(): void + { + $this->expectException(InvalidArgumentException::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(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported CloudEvents spec version: 1.1'); + + $event->validate(); + } + + public function testStrictDecodeRejectsUnknownSpecversionEvenWhenAllowed(): void + { + $this->expectException(InvalidArgumentException::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 testLenientDecodeStillRequiresEveryRequiredAttribute(): void + { + $sparse = []; + + foreach (['specversion' => '1.0', 'type' => 'test.event', 'source' => 'test-service', 'id' => 'test-id'] as $field => $value) { + try { + CloudEvent::fromArray($sparse, lenient: true); + $this->fail('Expected an exception for missing ' . $field); + } catch (InvalidArgumentException $e) { + $this->assertEquals('Missing required field: ' . $field, $e->getMessage()); + } + + $sparse[$field] = $value; + } + + $this->assertTrue(CloudEvent::fromArray($sparse, lenient: true)->validate()); + } + + public function testLenientDecodeAppliesToFromJson(): void + { + $event = CloudEvent::fromJson( + '{"specversion":"1.1","type":"test.event","source":"test-service","id":"test-id","subject":42,"traceParent":"invalid name"}', + lenient: true, + allowUnknownSpecversion: true + ); + + $this->assertEquals('1.1', $event->specversion); + $this->assertNull($event->subject); + $this->assertEquals([], $event->extensions); + } }