Repository navigation
chore: land PRs #7-#10 on main #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7bee9fd
e5b338b
7af61a4
f459111
79c6c0f
fcb2de9
e258ae3
c3be71f
71b3d8a
7658070
c167d85
1378845
769e171
a8f544d
5be68a8
f2283c2
f20f5b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| * | ||
|
|
@@ -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, | ||
|
|
@@ -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,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; | ||
| } | ||
|
|
||
| /** | ||
| * 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Prompt To Fix With AIThis 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. |
||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a caller constructs an event with an invalid extension such as
data_base64and serializes it without callingvalidate(),toArray()merges that extension directly into the output. With ordinarydata, this emits bothdataanddata_base64, producing JSON that the library's ownfromJson()rejects.Prompt To Fix With AI