Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/CloudEvents/CloudEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Utopia\CloudEvents;

use InvalidArgumentException;
use JsonException;

/**
* CloudEvent class representing the CloudEvents v1.0 specification
Expand Down Expand Up @@ -142,6 +143,84 @@ public function toArray(): array
return $array + $this->extensions;
}

/**
* Create CloudEvent from its JSON event format representation
*
* Binary payloads carried in the data_base64 member are decoded
* into data. 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);
}
}

/**
* Validate the CloudEvent
*
Expand Down
133 changes: 133 additions & 0 deletions tests/CloudEvents/CloudEventTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,139 @@ public function testExtensionRoundTrip(): void
$this->assertEquals($original->extensions, $restored->extensions);
}

public function testToJson(): void
{
$event = new CloudEvent(
type: 'user.created',
source: 'https://example.com/user-service',
id: 'event-1',
time: '2025-11-07T10:00:00Z',
datacontenttype: 'application/json',
data: ['userId' => '123']
);

$decoded = json_decode($event->toJson(), true);

$this->assertEquals([
'specversion' => '1.0',
'type' => 'user.created',
'source' => 'https://example.com/user-service',
'id' => 'event-1',
'time' => '2025-11-07T10:00:00Z',
'datacontenttype' => 'application/json',
'data' => ['userId' => '123']
], $decoded);
}

public function testFromJson(): void
{
$json = '{"specversion":"1.0","type":"user.created","source":"user-service","id":"event-1","data":{"userId":"123"},"traceparent":"00-abc-def-01"}';

$event = CloudEvent::fromJson($json);

$this->assertEquals('user.created', $event->type);
$this->assertEquals((object) ['userId' => '123'], $event->data);
$this->assertEquals('00-abc-def-01', $event->getExtension('traceparent'));
}

public function testFromJsonPreservesJsonDataTypes(): void
{
$json = '{"specversion":"1.0","type":"t","source":"s","id":"i","data":{"empty":{},"list":[]}}';

$restored = CloudEvent::fromJson($json)->toJson();

$this->assertStringContainsString('"empty":{}', $restored);
$this->assertStringContainsString('"list":[]', $restored);
}

public function testFromJsonRejectsArrayRoot(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('CloudEvent JSON must decode to an object');

CloudEvent::fromJson('[{"specversion":"1.0","type":"t","source":"s","id":"i"}]');
}

public function testFromJsonInvalidJson(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid CloudEvent JSON');

CloudEvent::fromJson('{not json');
}

public function testFromJsonNonObject(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('CloudEvent JSON must decode to an object');

CloudEvent::fromJson('"just a string"');
}

public function testJsonBinaryDataRoundTrip(): void
{
$binary = "\x89PNG\r\n\x1a\n\x00\x01\x02\x80\xff";

$event = new CloudEvent(
type: 'image.uploaded',
source: 'storage',
id: 'event-1',
datacontenttype: 'image/png',
data: $binary
);

$decoded = json_decode($event->toJson(), true);

$this->assertArrayNotHasKey('data', $decoded);
$this->assertEquals(base64_encode($binary), $decoded['data_base64']);

$restored = CloudEvent::fromJson($event->toJson());

$this->assertEquals($binary, $restored->data);
}

public function testFromJsonRejectsDataAndDataBase64(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('CloudEvent must not contain both data and data_base64');

CloudEvent::fromJson('{"specversion":"1.0","type":"t","source":"s","id":"i","data":"x","data_base64":"eA=="}');
}

public function testFromJsonRejectsInvalidBase64(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('data_base64 must be valid Base64');

CloudEvent::fromJson('{"specversion":"1.0","type":"t","source":"s","id":"i","data_base64":"!!!not-base64!!!"}');
}

public function testJsonRoundTrip(): void
{
$original = (new CloudEvent(
type: 'payment.processed',
source: 'https://example.com/payments',
id: 'event-123',
subject: 'payment-xyz',
time: '2025-11-07T10:00:00Z',
datacontenttype: 'application/json',
dataschema: 'https://example.com/schemas/payment.json',
data: ['paymentId' => 'xyz']
))->withExtension('traceparent', '00-abc-def-01');

$restored = CloudEvent::fromJson($original->toJson());

$this->assertEquals($original->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(
Expand Down
Loading