Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
193 changes: 182 additions & 11 deletions src/CloudEvents/CloudEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,30 @@
namespace Utopia\CloudEvents;

use InvalidArgumentException;
use JsonException;

/**
* CloudEvent class representing the CloudEvents v1.0 specification
* @see https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md
*/
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
*
Expand All @@ -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<string, mixed> $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<string, mixed> $extensions Extension attributes (lowercase alphanumeric names, boolean/integer/string values)
*/
public function __construct(
public readonly string $type,
Expand All @@ -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 = []
) {
}

Expand All @@ -53,35 +74,151 @@ 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'],
id: $array['id'],
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<string, mixed>
*/
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Invalid extensions reach serialized output

When a caller constructs an event with an invalid extension such as data_base64 and serializes it without calling validate(), toArray() merges that extension directly into the output. With ordinary data, this emits both data and data_base64, producing JSON that the library's own fromJson() rejects.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/CloudEvents/CloudEvent.php
Line: 143

Comment:
**Invalid extensions reach serialized output**

When a caller constructs an event with an invalid extension such as `data_base64` and serializes it without calling `validate()`, `toArray()` merges that extension directly into the output. With ordinary `data`, this emits both `data` and `data_base64`, producing JSON that the library's own `fromJson()` rejects.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

}

/**
* 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Binary representation is discarded

When data_base64 decodes to valid UTF-8 bytes, fromJson() stores only the decoded string and toJson() subsequently emits it as ordinary data. For example, "data_base64":"aGVsbG8=" becomes "data":"hello", causing downstream consumers to interpret an opaque binary payload as text or JSON.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/CloudEvents/CloudEvent.php
Line: 190

Comment:
**Binary representation is discarded**

When `data_base64` decodes to valid UTF-8 bytes, `fromJson()` stores only the decoded string and `toJson()` subsequently emits it as ordinary `data`. For example, `"data_base64":"aGVsbG8="` becomes `"data":"hello"`, causing downstream consumers to interpret an opaque binary payload as text or JSON.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

}

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);
}
}

/**
Expand Down Expand Up @@ -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);
}
}
}
Loading
Loading