Files
server/shared/lib/Json/JsonSerializableObject.php
T
2026-07-17 15:51:47 -04:00

215 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXF\Json;
use DateTimeInterface;
use DateTimeZone;
use DateInterval;
use KTXF\Json\JsonSerializable;
use KTXF\Json\JsonDeserializable;
abstract class JsonSerializableObject implements JsonSerializable, JsonDeserializable {
protected string $dateTimeFormat = 'c'; // ISO 8601 format by default
protected array $serializableProperties = []; // Empty array means serialize all properties
protected array $nonSerializableProperties = ['dateTimeFormat', 'serializableProperties', 'nonSerializableProperties']; // Properties to exclude from serialization
public function jsonSerialize(): mixed {
$vars = get_object_vars($this);
// Filter properties based on serializableProperties if specified
if (!empty($this->serializableProperties)) {
$vars = array_filter($vars, function($key) {
return in_array($key, $this->serializableProperties);
}, ARRAY_FILTER_USE_KEY);
}
// Process each property for special types
foreach ($vars as $key => $value) {
// Skip internal control properties
if (in_array($key, $this->nonSerializableProperties)) {
unset($vars[$key]);
continue;
}
// Handle DateTimeInterface (DateTime/DateTimeImmutable)
if ($value instanceof DateTimeInterface) {
$vars[$key] = $value->format($this->dateTimeFormat);
}
// Handle DateTimeZone
elseif ($value instanceof DateTimeZone) {
$vars[$key] = $value->getName();
}
// Handle DateInterval
elseif ($value instanceof DateInterval) {
$vars[$key] = $this->fromDateInterval($value);
}
// Handle backed enums
elseif ($value instanceof \BackedEnum) {
$vars[$key] = $value->value;
}
// Handle JsonSerializable objects
elseif ($value instanceof JsonSerializable) {
$vars[$key] = $value->jsonSerialize();
}
}
return $vars;
}
public function jsonDeserialize(array|string $data): static {
if (is_string($data)) {
$data = json_decode($data, true);
}
foreach ($data as $key => $value) {
if (property_exists($this, $key)) {
// Skip internal control properties
if (in_array($key, $this->nonSerializableProperties)) {
continue;
}
// Check if property should be deserialized (if serializableProperties is set)
if (!empty($this->serializableProperties) && !in_array($key, $this->serializableProperties)) {
continue;
}
// Null means absent: leave the property at its default rather
// than passing null into an object's jsonDeserialize/enum
// coercion or overwriting an initialized collection.
if ($value === null) {
continue;
}
$type = gettype($this->$key);
// Handle JsonDeserializable objects
if ($type === 'object' && $this->$key instanceof JsonDeserializable) {
$this->$key = $this->$key->jsonDeserialize($value);
}
// Handle DateTimeInterface (DateTime/DateTimeImmutable)
elseif ($type === 'object' && $this->$key instanceof DateTimeInterface) {
$this->$key = new \DateTimeImmutable($value);
}
// Handle DateTimeZone
elseif ($type === 'object' && $this->$key instanceof DateTimeZone) {
$this->$key = new DateTimeZone($value);
}
// Handle DateInterval
elseif ($type === 'object' && $this->$key instanceof DateInterval) {
$this->$key = $this->toDateInterval($value);
}
// Handle backed enums
elseif ($type === 'object' && $this->$key instanceof \BackedEnum) {
$enumClass = get_class($this->$key);
$this->$key = $enumClass::from($value);
}
// Handle regular values, coercing by declared type when the
// current value is null and carries no instance to key off
else {
$this->$key = $this->coerceValue($key, $value);
}
}
}
return $this;
}
/**
* Coerces a decoded JSON value to the property's declared type. Needed for
* properties whose current value is null: the instanceof-based branches
* above cannot dispatch without an instance, and assigning the raw string
* to a typed property (DateTime, enum, ...) would raise a TypeError.
*/
private function coerceValue(string $key, mixed $value): mixed {
$type = (new \ReflectionProperty($this, $key))->getType();
if ($type instanceof \ReflectionNamedType) {
$names = [$type->getName()];
} elseif ($type instanceof \ReflectionUnionType) {
$names = array_map(static fn($member) => $member->getName(), $type->getTypes());
} else {
return $value;
}
foreach ($names as $name) {
if (!class_exists($name) && !interface_exists($name) && !enum_exists($name)) {
continue;
}
if (is_string($value) && is_a($name, DateTimeInterface::class, true)) {
return $name === \DateTime::class ? new \DateTime($value) : new \DateTimeImmutable($value);
}
if (is_string($value) && is_a($name, DateTimeZone::class, true)) {
return new DateTimeZone($value);
}
if (is_string($value) && is_a($name, DateInterval::class, true)) {
return $this->toDateInterval($value);
}
if ((is_string($value) || is_int($value)) && is_subclass_of($name, \BackedEnum::class)) {
return $name::from($value);
}
if ((is_array($value) || is_string($value)) && is_subclass_of($name, JsonDeserializable::class)) {
/** @var JsonDeserializable $instance */
$instance = new $name();
return $instance->jsonDeserialize($value);
}
}
return $value;
}
protected function fromDateInterval(DateInterval $interval): string {
$spec = '';
// Handle negative intervals
if ($interval->invert === 1) {
$spec = '-';
}
$spec .= 'P';
if ($interval->y > 0) $spec .= $interval->y . 'Y';
if ($interval->m > 0) $spec .= $interval->m . 'M';
if ($interval->d > 0) $spec .= $interval->d . 'D';
$timePart = '';
if ($interval->h > 0) $timePart .= $interval->h . 'H';
if ($interval->i > 0) $timePart .= $interval->i . 'M';
if ($interval->s > 0) $timePart .= $interval->s . 'S';
if (!empty($timePart)) {
$spec .= 'T' . $timePart;
}
// Handle edge case of zero duration
if ($spec === 'P' || $spec === '-P') {
$spec = 'PT0S';
}
return $spec;
}
protected function toDateInterval(string $value): DateInterval {
$isNegative = false;
// Check for negative interval
if (str_starts_with($value, '-')) {
$isNegative = true;
$value = substr($value, 1);
}
// Create the interval
$interval = new DateInterval($value);
// Set invert property for negative intervals
if ($isNegative) {
$interval->invert = 1;
}
return $interval;
}
}