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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
- **Fixed:** `LaravelServiceProvider` read `ZAMMAD_URL` / `ZAMMAD_TOKEN` with `env()` from inside the container binding, i.e. at the moment the client was resolved. Under `php artisan config:cache` Laravel skips the `LoadEnvironmentVariables` bootstrapper altogether, so `.env` is never loaded and such a call returns its default — an application that kept its token in `.env` and never ran `vendor:publish` therefore worked in development and failed in production with *"API token must not be empty."*. `register()` now merges the packaged `config/zammad.php` via `mergeConfigFrom()`, which leaves the single `env()` call in the one file `config:cache` actually freezes. **Who is affected:** anyone setting an explicitly empty `url` or `token` in a published `config/zammad.php` while relying on the environment to fill it in — an explicit configuration value now wins over the environment instead of being treated as unset. The built-in URL default still applies to an empty value ([#169](https://github.com/zammad/zammad-api-client-php/issues/169))
- **Fixed:** the provider passed `mixed` configuration values straight into `ZammadClient::create()` and `Auth::token()`. Under `declare(strict_types=1)` a non-string value in a published `config/zammad.php` surfaced as a `TypeError` raised from inside the client, naming the client rather than the config file that had to be fixed. Values are now narrowed before use ([#169](https://github.com/zammad/zammad-api-client-php/issues/169))
- **Fixed:** `Cast::dateTime()` caught `DateMalformedStringException`, a class that only exists from PHP 8.3 on. The library supports PHP 8.1 and 8.2, where `new DateTimeImmutable()` throws a plain `Exception` for an unparsable value — the catch never matched and the exception escaped, contradicting both the method's documented `null` return and the class contract that these coercions never throw. It now catches `Exception`, which on 8.3 and later is the same thing. **Who is affected:** anyone on PHP 8.1 or 8.2 whose Zammad returns a timestamp the parser rejects ([#169](https://github.com/zammad/zammad-api-client-php/issues/169))
- Static analysis runs on PHPStan 2.x in all three projects, and the 21 findings that the newer analyser surfaced are corrected rather than suppressed — no baseline file is introduced. Most came from one cause: `get_object_vars()` is typed `array<array-key, mixed>`, because a numeric property name decodes to an integer key, so `toArray()` and `toPatchArray()` never actually delivered the `array<string, mixed>` they promised ([#176](https://github.com/zammad/zammad-api-client-php/issues/176))
- **Breaking:** the decoded-body returns of `RequestHandlerInterface` (`request()`, `get()`, `post()`, `put()`, `delete()`) are documented as `array<array-key, mixed>` instead of `array<string, mixed>`. That is what they have always returned: Zammad answers search endpoints with a top-level JSON array, which decodes to integer keys, and `ResponseParser` has handled that shape all along. The new `ResponseParser::fields()` narrows a body to the string-keyed shape where a single resource is expected. **Who is affected:** only code that runs static analysis against these methods and relied on the narrower declaration; there is no signature or runtime change ([#176](https://github.com/zammad/zammad-api-client-php/issues/176))
- **Breaking:** `PaginatedList::offsetGet()` and `offsetExists()` no longer return `null` / `false` for a non-integer offset. The class declares `ArrayAccess<int, T>`, so those guards were unreachable under the declared types; with them gone, `$list['some-string']` now raises a `TypeError` rather than silently yielding nothing. **Who is affected:** anyone indexing a `PaginatedList` with a non-integer offset, which was never part of the contract ([#176](https://github.com/zammad/zammad-api-client-php/issues/176))
- **Fixed:** header values passed through `$options['headers']` reached PSR-7's `withHeader()` unchecked, so a non-string value surfaced as an `InvalidArgumentException` raised from inside the transport layer rather than naming the caller's own input. Scalars are now coerced to strings and arrays mapped element-wise ([#176](https://github.com/zammad/zammad-api-client-php/issues/176))
- **Fixed:** the Symfony bundle fell back to `%env(ZAMMAD_TOKEN)%` with no default, so an application that registered the bundle without setting that variable got Symfony's `EnvNotFoundException: Environment variable not found: "ZAMMAD_TOKEN"` when it first resolved the client — an error about Symfony's env layer rather than about the missing credential. It now falls back through a `zammad.default_token` parameter, the same shape the URL already used, so the failure is the library's own `InvalidArgumentException: API token must not be empty.` — identical to what `LaravelServiceProvider` has always done. **Who is affected:** anyone relying on the previous exception type or message in a `catch` block or a health check ([#169](https://github.com/zammad/zammad-api-client-php/issues/169))
- Both framework bridges are verifiable by contributors. They are tested from separate Composer projects under `test/Bridge/Symfony/` and `test/Bridge/Laravel/`, each with its own `vendor/`, its own PHPStan run at level max and its own PHPUnit configuration. The Symfony suite loads, compiles and dumps a real `ContainerBuilder` — the path on which the bundle has broken before — and the Laravel suite registers the provider against a real `Illuminate\Foundation\Application`; both bridges go from 0 % to full line coverage. CI runs them against Symfony 6.4, 7.4 and 8.x and Laravel 12 and 13, and those ranges are stated in `suggest` and in the README. The split is not cosmetic: resolved in one lock file the two frameworks deadlock, because every Laravel release that tolerates PHPUnit 11 requires `symfony/http-kernel ^7` or newer. It also keeps both frameworks out of the root `require-dev`, so a plain `composer install` stays small. A `.gitattributes` with `export-ignore` rules additionally cuts the Composer dist archive roughly in half ([#169](https://github.com/zammad/zammad-api-client-php/issues/169))
- **Fixed:** the Symfony service IDs in the custom HTTP client guide. `@Symfony\Component\HttpClient\Psr18Client` and `@Nyholm\Psr7\Factory\Psr17Factory` are class names, not registered services, so copying that YAML failed the build. FrameworkBundle aliases `Psr\Http\Client\ClientInterface`, and the `nyholm/psr7` Flex recipe registers `nyholm.psr7.psr17_factory`. The fallback for installations without Flex, in that same section, then declared the factory under its class name while the handler referenced `nyholm.psr7.psr17_factory` — reproducing the very *"has a dependency on a non-existent service"* build error it was meant to avoid. It now declares the ID the handler asks for
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"require-dev": {
"phpunit/phpunit": "^11.0",
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan": "2.3.4",
"league/openapi-psr7-validator": "^0.22",
"squizlabs/php_codesniffer": "^4.0"
},
Expand Down
2 changes: 1 addition & 1 deletion src/Bridge/SymfonyBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
*/
final class SymfonyBundle extends Bundle
{
public function getContainerExtension(): ?ExtensionInterface
public function getContainerExtension(): ExtensionInterface
{
return new class implements ExtensionInterface {
/**
Expand Down
10 changes: 5 additions & 5 deletions src/Core/Contracts/RequestHandlerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ interface RequestHandlerInterface
* PSR-18 client, which allows setting custom headers or timeouts per call.
*
* @param array<string, mixed> $options PSR-18 client options (e.g. `['json' => [...]]`).
* @return array<string, mixed> JSON-decoded response body.
* @return array<array-key, mixed> JSON-decoded response body.
* @throws \ZammadAPIClient\Exceptions\AuthenticationException For 401 responses.
* @throws \ZammadAPIClient\Exceptions\NotFoundException For 404 responses.
* @throws \ZammadAPIClient\Exceptions\ValidationException For 422 responses.
Expand Down Expand Up @@ -59,23 +59,23 @@ public function getRaw(string $uri, array $query = [], array $headers = []): str
* Performs a GET request and returns the decoded JSON body.
*
* @param array<string, mixed> $query URL query parameters appended to the URI.
* @return array<string, mixed>
* @return array<array-key, mixed>
*/
public function get(string $uri, array $query = []): array;

/**
* Performs a POST request with a JSON body and returns the decoded response.
*
* @param array<string, mixed> $body Request payload; serialised to JSON automatically.
* @return array<string, mixed>
* @return array<array-key, mixed>
*/
public function post(string $uri, array $body = []): array;

/**
* Performs a PUT request with a JSON body and returns the decoded response.
*
* @param array<string, mixed> $body Request payload; serialised to JSON automatically.
* @return array<string, mixed>
* @return array<array-key, mixed>
*/
public function put(string $uri, array $body = []): array;

Expand All @@ -85,7 +85,7 @@ public function put(string $uri, array $body = []): array;
* Zammad's DELETE endpoints return the deleted resource's data, which
* callers may use to confirm the operation.
*
* @return array<string, mixed>
* @return array<array-key, mixed>
*/
public function delete(string $uri): array;

Expand Down
30 changes: 21 additions & 9 deletions src/Core/Repository/AbstractRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ protected function getListKey(): string
public function find(int $id): DTOInterface
{
return $this->dtoClass::fromArray(
$this->handler->get("{$this->resourcePath}/{$id}", ['expand' => 'true']),
ResponseParser::fields($this->handler->get("{$this->resourcePath}/{$id}", ['expand' => 'true'])),
);
}

Expand Down Expand Up @@ -198,7 +198,7 @@ public function allPage(int $page, int $perPage, array $query = []): array
'per_page' => (string) $perPage,
]);

return $this->hydrateList($this->handler->get($this->resourcePath, $params));
return $this->hydrateList(ResponseParser::fields($this->handler->get($this->resourcePath, $params)));
}

/**
Expand All @@ -217,7 +217,7 @@ public function searchPage(string $term, int $page, int $perPage, array $query =
'per_page' => (string) $perPage,
]);

return $this->hydrateList($this->handler->get("{$this->resourcePath}/search", $params));
return $this->hydrateList(ResponseParser::fields($this->handler->get("{$this->resourcePath}/search", $params)));
}

/**
Expand Down Expand Up @@ -247,7 +247,7 @@ protected function paginateWith(

do {
$params = array_merge($query, ['page' => (string) $page, 'per_page' => (string) $this->pageSize]);
$items = $this->extractItems($this->handler->get($endpoint, $params), $listKey);
$items = $this->extractItems(ResponseParser::fields($this->handler->get($endpoint, $params)), $listKey);

foreach ($items as $item) {
yield $dtoClass::fromArray($item);
Expand Down Expand Up @@ -317,7 +317,9 @@ public function totalCount(): int
*/
public function create(DTOInterface $dto): DTOInterface
{
return $this->dtoClass::fromArray($this->handler->post($this->resourcePath, $dto->toArray()));
return $this->dtoClass::fromArray(
ResponseParser::fields($this->handler->post($this->resourcePath, $dto->toArray())),
);
}

/**
Expand Down Expand Up @@ -346,13 +348,23 @@ public function patch(int $id, array|object $changes): DTOInterface
$body = $changes->toArray();
} elseif ($changes instanceof PatchableInterface) {
$body = $changes->toPatchArray();
} elseif (is_object($changes)) {
$body = array_filter(get_object_vars($changes), fn($v) => $v !== null);
} else {
$body = array_filter($changes, fn($v) => $v !== null);
// get_object_vars() and a caller-supplied array are both typed
// array<array-key, mixed>; rebuild with string keys so the declared
// body type holds. See SerializesToArray::toArray().
$body = [];
$source = is_object($changes) ? get_object_vars($changes) : $changes;

foreach ($source as $key => $value) {
if ($value !== null) {
$body[(string) $key] = $value;
}
}
}

return $this->dtoClass::fromArray($this->handler->put("{$this->resourcePath}/{$id}", $body));
return $this->dtoClass::fromArray(
ResponseParser::fields($this->handler->put("{$this->resourcePath}/{$id}", $body)),
);
}

/**
Expand Down
8 changes: 0 additions & 8 deletions src/Core/Repository/PaginatedList.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,6 @@ public function first(): ?DTOInterface
/** @return T|null */
public function offsetGet(mixed $offset): mixed
{
if (!is_int($offset)) {
return null;
}

$page = (int) floor($offset / $this->perPage) + 1;
$index = $offset % $this->perPage;

Expand All @@ -78,10 +74,6 @@ public function offsetGet(mixed $offset): mixed

public function offsetExists(mixed $offset): bool
{
if (!is_int($offset)) {
return false;
}

$page = (int) floor($offset / $this->perPage) + 1;

if (!isset($this->pageCache[$page])) {
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Repository/Resource.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ public function save(): self
return $this;
}

$this->attributes = array_merge($this->attributes, $data);
$this->attributes = array_merge($this->attributes, ResponseParser::fields($data));
$this->dto = $this->dto::fromArray($this->attributes);
$this->changes = [];
$this->newRecord = false;
Expand Down
24 changes: 24 additions & 0 deletions src/Core/Repository/ResponseParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@
*/
final class ResponseParser
{
/**
* Narrows a decoded response body to the string-keyed shape DTOs expect.
*
* A JSON object decodes to string keys, but a decoded body is typed
* `array<array-key, mixed>` because Zammad answers some endpoints with a
* top-level JSON array, which decodes to integer keys. Single-resource
* endpoints always answer with an object, so this states that expectation
* at the one place it is made instead of asserting it silently at each
* call site.
*
* @param array<array-key, mixed> $data
* @return array<string, mixed>
*/
public static function fields(array $data): array
{
$fields = [];

foreach ($data as $key => $value) {
$fields[(string) $key] = $value;
}

return $fields;
}

/**
* Extracts item arrays from an API response.
*
Expand Down
18 changes: 15 additions & 3 deletions src/Core/Traits/SerializesToArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,26 @@ public function toArray(): array
$vars = get_object_vars($this);
foreach ($vars as $key => $value) {
if ($value !== null && $key !== 'customFields') {
$result[$key] = $value instanceof DateTimeImmutable ? $value->format('c') : $value;
// get_object_vars() is typed array<array-key, mixed>: a numeric
// property name would come back as an int key. Promoted readonly
// properties cannot be numeric, but the trait is generic, so the
// cast keeps the declared array<string, mixed> true either way.
$result[(string) $key] = $value instanceof DateTimeImmutable
? $value->format('c')
: $value;
}
}

if (array_key_exists('customFields', $vars) && is_array($vars['customFields'])) {
foreach ($vars['customFields'] as $k => $v) {
if (!in_array($k, self::serverReadOnlyKeys(), true)) {
$result[$k] = $v;
// Zammad field names are strings, but a plain array permits
// integer keys. Casting keeps the declared array<string, mixed>
// honest and makes the strict in_array() below meaningful for
// keys that arrived as integers.
$key = (string) $k;

if (!in_array($key, self::serverReadOnlyKeys(), true)) {
$result[$key] = $v;
}
}
}
Expand Down
34 changes: 27 additions & 7 deletions src/Core/Transport/HttpPageFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ public function fetch(int $page, int $perPage, array $baseQuery): array

$data = $this->handler->get($this->endpoint, $params);

$fields = ResponseParser::fields($data);

return $isSearch
? $this->extractSearchResults($data)
: $this->extractIndexResults($data);
? $this->extractSearchResults($fields)
: $this->extractIndexResults($fields);
}

/**
Expand Down Expand Up @@ -92,16 +94,34 @@ private function extractIndexResults(array $data): array
}

/**
* @param array<int, array<string, mixed>> $items
* Hydrates whatever item arrays are in $items, ignoring everything else.
*
* The parameter is deliberately as loose as the method's own filtering:
* search responses hand over a raw `records` value, and claiming a narrower
* type here would only move the unchecked cast to the caller.
*
* @param array<array-key, mixed> $items
* @return list<T>
*/
private function hydrateItems(array $items): array
{
$hydrated = [];

foreach ($items as $item) {
if (!is_array($item)) {
continue;
}

$fields = [];
foreach ($item as $key => $value) {
$fields[(string) $key] = $value;
}

$hydrated[] = $this->dtoClass::fromArray($fields);
}

/** @var list<T> */
return array_map(
fn(array $item): DTOInterface => $this->dtoClass::fromArray($item),
array_values(array_filter($items, 'is_array')),
);
return $hydrated;
}

private function inferListKey(): string
Expand Down
Loading
Loading