From d96405f8507fb503e1a481b1e7205d6862bce429 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:06:11 +0200 Subject: [PATCH 01/10] feat(S2): Add UserDTO with typed fields (login, email, roles, active, etc.) and lenient fromArray parsing EPIC-#79 #93 --- src/Endpoints/Users/UserDTO.php | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/Endpoints/Users/UserDTO.php diff --git a/src/Endpoints/Users/UserDTO.php b/src/Endpoints/Users/UserDTO.php new file mode 100644 index 0000000..a713c28 --- /dev/null +++ b/src/Endpoints/Users/UserDTO.php @@ -0,0 +1,49 @@ + Date: Mon, 13 Jul 2026 18:06:11 +0200 Subject: [PATCH 02/10] feat(S2): Add OrganizationDTO with typed fields (name, domain, members, note, etc.) EPIC-#79 #94 --- .../Organizations/OrganizationDTO.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/Endpoints/Organizations/OrganizationDTO.php diff --git a/src/Endpoints/Organizations/OrganizationDTO.php b/src/Endpoints/Organizations/OrganizationDTO.php new file mode 100644 index 0000000..bcdc107 --- /dev/null +++ b/src/Endpoints/Organizations/OrganizationDTO.php @@ -0,0 +1,37 @@ + Date: Mon, 13 Jul 2026 18:06:11 +0200 Subject: [PATCH 03/10] feat(S2): Add GroupDTO with typed fields (name, signature_id, email_address_id, follow_up_*, active, etc.) EPIC-#79 #95 --- src/Endpoints/Groups/GroupDTO.php | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/Endpoints/Groups/GroupDTO.php 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 @@ + Date: Mon, 13 Jul 2026 18:06:11 +0200 Subject: [PATCH 04/10] feat(S2): Add TicketArticleDTO with typed fields (ticket_id, type, sender, subject, body, content_type, etc.) EPIC-#79 #96 --- .../TicketArticles/TicketArticleDTO.php | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/Endpoints/TicketArticles/TicketArticleDTO.php diff --git a/src/Endpoints/TicketArticles/TicketArticleDTO.php b/src/Endpoints/TicketArticles/TicketArticleDTO.php new file mode 100644 index 0000000..e64b73b --- /dev/null +++ b/src/Endpoints/TicketArticles/TicketArticleDTO.php @@ -0,0 +1,46 @@ + Date: Mon, 13 Jul 2026 18:06:11 +0200 Subject: [PATCH 05/10] feat(S2): Add TicketStateDTO with typed fields (name, state_type_id, active, note, etc.) EPIC-#79 #97 --- src/Endpoints/TicketStates/TicketStateDTO.php | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/Endpoints/TicketStates/TicketStateDTO.php diff --git a/src/Endpoints/TicketStates/TicketStateDTO.php b/src/Endpoints/TicketStates/TicketStateDTO.php new file mode 100644 index 0000000..52c0ded --- /dev/null +++ b/src/Endpoints/TicketStates/TicketStateDTO.php @@ -0,0 +1,43 @@ + Date: Mon, 13 Jul 2026 18:06:11 +0200 Subject: [PATCH 06/10] feat(S2): Add TicketPriorityDTO with typed fields (name, default_create, ui_icon, ui_color, active, etc.) EPIC-#79 #98 --- .../TicketPriorities/TicketPriorityDTO.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/Endpoints/TicketPriorities/TicketPriorityDTO.php diff --git a/src/Endpoints/TicketPriorities/TicketPriorityDTO.php b/src/Endpoints/TicketPriorities/TicketPriorityDTO.php new file mode 100644 index 0000000..b993900 --- /dev/null +++ b/src/Endpoints/TicketPriorities/TicketPriorityDTO.php @@ -0,0 +1,37 @@ + Date: Mon, 13 Jul 2026 18:06:11 +0200 Subject: [PATCH 07/10] feat(S2): Add TagDTO with typed fields (name, count, etc.) EPIC-#79 #99 --- src/Endpoints/Tags/TagDTO.php | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/Endpoints/Tags/TagDTO.php diff --git a/src/Endpoints/Tags/TagDTO.php b/src/Endpoints/Tags/TagDTO.php new file mode 100644 index 0000000..3ec5222 --- /dev/null +++ b/src/Endpoints/Tags/TagDTO.php @@ -0,0 +1,42 @@ + Date: Mon, 13 Jul 2026 18:06:11 +0200 Subject: [PATCH 08/10] feat(S2): Add TextModuleDTO with typed fields (name, keywords, content, active, etc.) EPIC-#79 #100 --- src/Endpoints/TextModules/TextModuleDTO.php | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/Endpoints/TextModules/TextModuleDTO.php diff --git a/src/Endpoints/TextModules/TextModuleDTO.php b/src/Endpoints/TextModules/TextModuleDTO.php new file mode 100644 index 0000000..86f4c0b --- /dev/null +++ b/src/Endpoints/TextModules/TextModuleDTO.php @@ -0,0 +1,45 @@ + Date: Mon, 13 Jul 2026 18:06:11 +0200 Subject: [PATCH 09/10] =?UTF-8?q?feat(S2):=20Complete=20typed=20DTOs=20for?= =?UTF-8?q?=20all=209=20resources=20=E2=80=94=20Ticket,=20User,=20Organiza?= =?UTF-8?q?tion,=20Group,=20TicketArticle,=20TicketState,=20TicketPriority?= =?UTF-8?q?,=20Tag,=20TextModule=20+=20TicketUpdateDTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EPIC-#79 #81 From 02fd1cf715f55d3fafe20edf55b4e2a8c96160e6 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 18:12:44 +0200 Subject: [PATCH 10/10] feat(S2): Add DTOInterface, reflection-based DtoHydrator, and HydratesFromArray/SerializesToArray traits EPIC-#79 #91 --- src/Core/Contracts/DTOInterface.php | 69 +++++++++++++++++++++ src/Core/DtoHydrator.php | 89 +++++++++++++++++++++++++++ src/Core/Traits/HydratesFromArray.php | 40 ++++++++++++ src/Core/Traits/SerializesToArray.php | 71 +++++++++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 src/Core/Contracts/DTOInterface.php create mode 100644 src/Core/DtoHydrator.php create mode 100644 src/Core/Traits/HydratesFromArray.php create mode 100644 src/Core/Traits/SerializesToArray.php 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(); + } +}