diff --git a/src/Core/Contracts/DTOInterface.php b/src/Core/Contracts/DTOInterface.php new file mode 100644 index 0000000..fed6fc6 --- /dev/null +++ b/src/Core/Contracts/DTOInterface.php @@ -0,0 +1,69 @@ + $data Raw JSON-decoded response from the API. + * @return static + */ + public static function fromArray(array $data): static; + + /** + * Serializes the DTO to an associative array suitable for API requests. + * + * Keys match the API field names. Null values are included so that + * callers can distinguish "not set" from "set to empty string". + * + * @return array + */ + public function toArray(): array; + + /** + * Returns the server-assigned ID, or null before the object is persisted. + * + * The ID is assigned by Zammad on creation and is never set by the client. + * A null return value signals that the DTO represents an unsaved resource. + */ + public function id(): ?int; + + /** + * Alias of {@see self::toArray()} required by JsonSerializable. + * + * Enables direct JSON encoding: `json_encode($dto)` produces the same + * output as `json_encode($dto->toArray())`. + * + * @return array + */ + public function jsonSerialize(): array; +} diff --git a/src/Core/DtoHydrator.php b/src/Core/DtoHydrator.php new file mode 100644 index 0000000..fd0c3f6 --- /dev/null +++ b/src/Core/DtoHydrator.php @@ -0,0 +1,89 @@ +> + */ + private static array $metaCache = []; + + /** + * Instantiates $class by mapping $data array keys to constructor parameters. + * + * The constructor is introspected once per class and the result is cached + * in {@see self::$metaCache} to avoid repeated reflection calls. Each + * parameter's declared type drives the coercion applied via {@see Cast}: + * e.g. a `?DateTimeImmutable` parameter gets `Cast::dateTime()`, a + * `string` parameter gets `Cast::string()`, etc. Parameters for which no + * key exists in $data receive null (nullable) or a zero-value (non-nullable). + * + * @template T of object + * @param class-string $class Fully-qualified DTO class to instantiate. + * @param array $data Raw API response fields. + * @return T + */ + public static function hydrate(string $class, array $data): object + { + $args = []; + + foreach (self::constructorMeta($class) as $param) { + $args[] = self::coerce($param['type'], $param['nullable'], $data, $param['name']); + } + + return new $class(...$args); + } + + /** + * @param array $data + */ + private static function coerce(?string $type, bool $nullable, array $data, string $name): mixed + { + return match ($type) { + DateTimeImmutable::class => Cast::dateTime($data, $name), + 'int' => $nullable ? Cast::intOrNull($data, $name) : (Cast::intOrNull($data, $name) ?? 0), + 'bool' => $nullable ? Cast::boolOrNull($data, $name) : (Cast::boolOrNull($data, $name) ?? false), + 'string' => $nullable ? Cast::stringOrNull($data, $name) : Cast::string($data, $name), + default => $data[$name] ?? null, + }; + } + + /** + * @param class-string $class + * @return list + */ + private static function constructorMeta(string $class): array + { + if (isset(self::$metaCache[$class])) { + return self::$metaCache[$class]; + } + + $constructor = (new ReflectionClass($class))->getConstructor(); + $meta = []; + + foreach ($constructor?->getParameters() ?? [] as $param) { + $type = $param->getType(); + $meta[] = [ + 'name' => $param->getName(), + 'type' => $type instanceof ReflectionNamedType ? $type->getName() : null, + 'nullable' => $type === null || $type->allowsNull(), + ]; + } + + return self::$metaCache[$class] = $meta; + } +} diff --git a/src/Core/Traits/HydratesFromArray.php b/src/Core/Traits/HydratesFromArray.php new file mode 100644 index 0000000..f394c3b --- /dev/null +++ b/src/Core/Traits/HydratesFromArray.php @@ -0,0 +1,40 @@ + $data Raw JSON-decoded API response. + * @return static + */ + public static function fromArray(array $data): static + { + return DtoHydrator::hydrate(static::class, $data); + } +} diff --git a/src/Core/Traits/SerializesToArray.php b/src/Core/Traits/SerializesToArray.php new file mode 100644 index 0000000..23ea5e6 --- /dev/null +++ b/src/Core/Traits/SerializesToArray.php @@ -0,0 +1,71 @@ + + */ + public function toArray(): array + { + $result = []; + foreach (get_object_vars($this) as $key => $value) { + if ($value !== null) { + $result[$key] = $value instanceof DateTimeImmutable ? $value->format('c') : $value; + } + } + + return $result; + } + + /** + * Returns the server-assigned resource ID, or null for unsaved DTOs. + * + * The consuming class must declare `public readonly ?int $id = null`. + * This method reads it directly; no property access indirection is used. + */ + public function id(): ?int + { + return $this->id; + } + + /** + * Delegates to {@see self::toArray()} to satisfy the JsonSerializable contract. + * + * Allows `json_encode($dto)` to produce the same output as + * `json_encode($dto->toArray())` without any extra code in the DTO. + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Endpoints/Groups/GroupDTO.php b/src/Endpoints/Groups/GroupDTO.php new file mode 100644 index 0000000..e231e0b --- /dev/null +++ b/src/Endpoints/Groups/GroupDTO.php @@ -0,0 +1,37 @@ +