Skip to content
Closed
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
152 changes: 118 additions & 34 deletions src/CloudEvents/CloudEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $extensions Extension attributes (lowercase alphanumeric names, boolean/integer/string values)
* @param array<array-key, mixed> $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(
Expand All @@ -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);
}
}
}
Expand All @@ -94,39 +94,38 @@ public static function now(): string
* absent (per the JSON format, absent datacontenttype already
* implies a JSON payload).
*
* @param array<string, mixed> $array
* @param array<array-key, mixed> $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
);
}
Expand All @@ -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<string, mixed>
* @return array<array-key, mixed>
*/
public function toArray(): array
{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -218,7 +219,7 @@ public static function fromJson(string $json): self
$decoded['data'] = $binary;
}

return self::fromArray($decoded);
return self::fromArray($decoded, $lenient, $allowUnknownSpecversion);
}

/**
Expand Down Expand Up @@ -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-key, mixed> $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-key, mixed> $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<array-key, mixed> $extensions
* @param bool $lenient
* @return array<array-key, mixed>
*/
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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

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