Skip to content
Merged
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
69 changes: 69 additions & 0 deletions src/Core/Contracts/DTOInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient\Core\Contracts;

use JsonSerializable;

/**
* Marks a class as an immutable API data-transfer object.
*
* DTOs carry structured data between the HTTP layer and the application.
* They are intentionally read-only: all properties are set during construction
* and must not be mutated afterwards. Implementing classes should use PHP 8.1+
* promoted readonly constructor parameters so that the constructor itself
* serves as the authoritative schema of the API resource.
*
* Hydration from raw API responses is provided via {@see self::fromArray()}.
* Serialization back to the wire format is provided via {@see self::toArray()}
* and {@see self::jsonSerialize()}.
*
* The {@see \ZammadAPIClient\Core\Traits\HydratesFromArray} and
* {@see \ZammadAPIClient\Core\Traits\SerializesToArray} traits supply
* default reflection-based implementations; override only when the API
* shape does not map 1:1 to the constructor parameter names.
*/
interface DTOInterface extends JsonSerializable
{
/**
* Constructs a DTO from a raw API response array.
*
* Field names in $data are matched to constructor parameter names using
* reflection (via {@see \ZammadAPIClient\Core\DtoHydrator}). Unknown keys
* are silently ignored; missing keys resolve to the parameter's default
* value or null for nullable parameters.
*
* @param array<string, mixed> $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<string, mixed>
*/
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<string, mixed>
*/
public function jsonSerialize(): array;
}
89 changes: 89 additions & 0 deletions src/Core/DtoHydrator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient\Core;

use DateTimeImmutable;
use ReflectionClass;
use ReflectionNamedType;

/**
* Type-driven DTO hydration: maps array keys onto constructor parameters using
* each parameter's declared type. The constructor is the single source of
* truth; scalar coercion is delegated to {@see Cast}.
*/
final class DtoHydrator
{
/**
* Cached constructor metadata per class: ordered list of name/type/nullable.
*
* @var array<class-string, list<array{name: string, type: ?string, nullable: bool}>>
*/
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<T> $class Fully-qualified DTO class to instantiate.
* @param array<string, mixed> $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<string, mixed> $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<array{name: string, type: ?string, nullable: bool}>
*/
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;
}
}
40 changes: 40 additions & 0 deletions src/Core/Traits/HydratesFromArray.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient\Core\Traits;

use ZammadAPIClient\Core\DtoHydrator;

/**
* Provides a reflection-based `fromArray()` implementation for DTOs.
*
* Delegates to {@see \ZammadAPIClient\Core\DtoHydrator::hydrate()}, which
* inspects the consuming class's constructor to determine parameter names and
* types, then maps matching keys from the raw API array using
* {@see \ZammadAPIClient\Core\Cast}. The constructor is the sole schema
* definition — no separate mapping configuration is needed.
*
* When the API field name differs from the constructor parameter, or when
* a fallback key must be tried (e.g. `owner_id` vs `owner`), override
* `fromArray()` in the specific DTO and call `parent::fromArray()` only
* after normalising the array. See {@see \ZammadAPIClient\Endpoints\Tickets\TicketDTO}
* for an example.
*/
trait HydratesFromArray
{
/**
* Constructs a DTO from a raw API response array.
*
* Parameter names drive the field lookup: a constructor parameter named
* `$ticketId` will read $data['ticketId']. The hydration is case-sensitive
* and does not snake-case-convert parameter names.
*
* @param array<string, mixed> $data Raw JSON-decoded API response.
* @return static
*/
public static function fromArray(array $data): static
{
return DtoHydrator::hydrate(static::class, $data);
}
}
71 changes: 71 additions & 0 deletions src/Core/Traits/SerializesToArray.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient\Core\Traits;

use DateTimeImmutable;

/**
* Provides standard DTO serialisation for classes with promoted readonly properties.
*
* Consuming DTOs must declare a public nullable `$id` property so that
* {@see self::id()} can return it. All other public properties are included
* in {@see self::toArray()} automatically via `get_object_vars()`.
*
* Serialisation rules:
* - Null values are omitted so the API only receives explicitly set fields.
* - DateTimeImmutable values are formatted as ISO 8601 strings (`c` format).
* - All other scalar values are included as-is.
*
* Pair with {@see \ZammadAPIClient\Core\Traits\HydratesFromArray} for a
* complete, zero-boilerplate DTO implementation.
*/
trait SerializesToArray
{
/**
* Serialises all non-null public properties to an associative array.
*
* The resulting array is suitable for API request bodies. DateTimeImmutable
* values are converted to ISO 8601 strings; all other values are passed
* through. Null properties are excluded so partial DTO construction does
* not send empty fields to the API.
*
* @return array<string, mixed>
*/
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<string, mixed>
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}
37 changes: 37 additions & 0 deletions src/Endpoints/Groups/GroupDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient\Endpoints\Groups;

use DateTimeImmutable;
use ZammadAPIClient\Core\Contracts\DTOInterface;
use ZammadAPIClient\Core\Traits\HydratesFromArray;
use ZammadAPIClient\Core\Traits\SerializesToArray;

/**
* Represents a Zammad group resource (`/api/v1/groups`).
*
* Groups are routing containers for tickets. Every ticket belongs to exactly one
* group, which determines which agents can see and work on it. Groups also control
* SLA assignments, notification rules, and signature selection.
*
* Server-assigned fields (`id`, `created_at`, `updated_at`) default to null so
* the DTO can be constructed before persisting. After a `create()` or `find()` call
* the returned DTO will have these fields populated by the server.
*/
final readonly class GroupDTO implements DTOInterface
{
use HydratesFromArray;
use SerializesToArray;

public function __construct(
public string $name,
public ?string $note = null,
public ?bool $active = null,
public ?int $id = null,
public ?DateTimeImmutable $created_at = null,
public ?DateTimeImmutable $updated_at = null,
) {
}
}
37 changes: 37 additions & 0 deletions src/Endpoints/Organizations/OrganizationDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient\Endpoints\Organizations;

use DateTimeImmutable;
use ZammadAPIClient\Core\Contracts\DTOInterface;
use ZammadAPIClient\Core\Traits\HydratesFromArray;
use ZammadAPIClient\Core\Traits\SerializesToArray;

/**
* Represents a Zammad organization resource (`/api/v1/organizations`).
*
* Organizations group customer users under a shared company entity. A customer
* can belong to one primary organization; agents and organizations are separate
* (agents typically have no organization). Tickets created by a customer inherit
* the customer's organization, enabling company-wide ticket views.
*
* Server-assigned fields (`id`, `created_at`, `updated_at`) default to null and
* are populated by the API after `create()` or `find()`.
*/
final readonly class OrganizationDTO implements DTOInterface
{
use HydratesFromArray;
use SerializesToArray;

public function __construct(
public string $name,
public ?string $note = null,
public ?bool $active = null,
public ?int $id = null,
public ?DateTimeImmutable $created_at = null,
public ?DateTimeImmutable $updated_at = null,
) {
}
}
42 changes: 42 additions & 0 deletions src/Endpoints/Tags/TagDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient\Endpoints\Tags;

use ZammadAPIClient\Core\Contracts\DTOInterface;
use ZammadAPIClient\Core\Traits\HydratesFromArray;
use ZammadAPIClient\Core\Traits\SerializesToArray;

/**
* Represents a single tag assignment on a Zammad object (`/api/v1/tags`).
*
* Tags in Zammad are not global labels by themselves; they exist as associations
* between a tag name (`value`) and a specific taggable object (identified by
* its type name `object` and numeric ID `o_id`).
*
* Field semantics:
* - `value` — The human-readable tag string (e.g. `'bug'`, `'urgent'`).
* - `object` — Zammad object class name that the tag is attached to (e.g. `'Ticket'`).
* - `o_id` — The numeric ID of the specific object instance being tagged.
*
* All fields are nullable because the `/tag_search` autocomplete endpoint returns
* only `id` and `value` without `object` or `o_id`.
*
* Note: The `id` field here is the tag-assignment ID, not the tag label's ID in
* the Zammad tag list. Two identical tag strings on different tickets have
* different assignment IDs.
*/
final readonly class TagDTO implements DTOInterface
{
use HydratesFromArray;
use SerializesToArray;

public function __construct(
public ?int $id,
public ?string $object,
public ?int $o_id,
public ?string $value,
) {
}
}
Loading
Loading