From 2e18eab779ca5cf37d9da5625db83e95241e6594 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 20 Jul 2026 16:46:23 +0200 Subject: [PATCH 1/2] refactor: split ZammadClient into three concerns (ClientFactory, ImpersonationHandler, slim client) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the monolithic ZammadClient into focused classes: - ClientFactory → GuzzleClientFactory implements ClientFactoryInterface Guzzle wiring lives exclusively in GuzzleClientFactory::buildClient() Non-Guzzle via new ZammadClient(new RequestHandler(...)) - ImpersonationHandler — stateless decorator implementing RequestHandlerInterface. Injects From header on every request including getRaw(). No shared mutable state. - ZammadClient — slimmed from ~210 to ~79 lines. Only repo() and getHandler(). Repository access via typed, explicit, IDE-friendly methods (ticket(), user(), group(), etc.) implemented directly on the class. Removals and cleanups: - __call, aliasMap, resolveAlias — replaced by explicit typed methods - RequestHandler:: — shared mutable state removed - RequestHandlerInterface::setOnBehalfOfUser/getOnBehalfOfUser — removed - RequestHandlerInterface::getRaw() extended with $headers parameter for ImpersonationHandler compatibility - onBehalfOf() / performOnBehalfOf() — not a Client concern; use new ZammadClient(new ImpersonationHandler($handler, $userId)) - getListKey() default $this->resourcePath; 10 identical impls removed Namespace structure: Core/Contracts/ — ClientInterface, ClientFactoryInterface Core/Repository/ — AbstractRepository, RepositoryRegistry, PaginatedList, Resource, ResponseParser, DtoHydrator Core/Transport/ — RequestHandler, RetryAfterMiddleware, ImpersonationHandler, HttpPageFetcher Core/Traits/ — RepositoryAccessors (opt-in for custom ClientInterface impls), HasTimestamps, HydratesFromArray, SerializesToArray Factory/ — GuzzleClientFactory Bug fixes: - HttpPageFetcher::extractIndexResults() now reads total_count from API response instead of hard-coding null --- .gitignore | 2 + .opencode/plans/refactoring-plan.md | 589 ------------------ CHANGELOG.md | 20 +- examples/cookbook.php | 21 +- phpstan.neon.dist | 4 - src/Bridge/LaravelServiceProvider.php | 5 +- src/Bridge/SymfonyBundle.php | 5 +- src/Core/Cast.php | 2 +- src/Core/Contracts/ClientFactoryInterface.php | 12 + src/Core/Contracts/ClientInterface.php | 5 +- src/Core/Contracts/DTOInterface.php | 2 +- src/Core/Contracts/PageFetcherInterface.php | 2 +- .../Contracts/RequestHandlerInterface.php | 19 +- .../{ => Repository}/AbstractRepository.php | 27 +- src/Core/{ => Repository}/DtoHydrator.php | 3 +- src/Core/{ => Repository}/PaginatedList.php | 7 +- .../{ => Repository}/RepositoryRegistry.php | 2 +- src/Core/{ => Repository}/Resource.php | 2 +- src/Core/{ => Repository}/ResponseParser.php | 2 +- src/Core/Traits/HydratesFromArray.php | 6 +- src/Core/Traits/RepositoryAccessors.php | 72 +++ src/Core/{ => Transport}/HttpPageFetcher.php | 13 +- src/Core/Transport/ImpersonationHandler.php | 73 +++ src/Core/{ => Transport}/RequestHandler.php | 39 +- .../{ => Transport}/RetryAfterMiddleware.php | 2 +- src/Endpoints/Groups/GroupRepository.php | 7 +- src/Endpoints/Links/LinkRepository.php | 7 +- .../Organizations/OrganizationRepository.php | 10 +- src/Endpoints/Tags/TagRepository.php | 10 +- .../TextModules/TextModuleRepository.php | 7 +- .../TicketArticleRepository.php | 12 +- .../TicketPriorityRepository.php | 9 +- .../TicketStates/TicketStateRepository.php | 9 +- src/Endpoints/Tickets/TicketDTO.php | 2 +- src/Endpoints/Tickets/TicketRepository.php | 13 +- src/Endpoints/Tickets/TicketUpdateDTO.php | 4 +- src/Endpoints/Users/UserRepository.php | 10 +- src/Exceptions/NetworkException.php | 2 +- src/Factory/GuzzleClientFactory.php | 87 +++ src/ZammadClient.php | 309 ++------- .../Traits/CreatesZammadClient.php | 9 +- test/Unit/Core/AbstractRepositoryTest.php | 4 +- test/Unit/Core/HttpPageFetcherTest.php | 6 +- test/Unit/Core/ImpersonationHandlerTest.php | 175 ++++++ test/Unit/Core/PaginatedListTest.php | 4 +- test/Unit/Core/RepositoryRegistryTest.php | 2 +- test/Unit/Core/RequestHandlerTest.php | 38 +- test/Unit/Core/ResourceTest.php | 4 +- test/Unit/Core/ResponseParserTest.php | 2 +- test/Unit/Core/RetryAfterMiddlewareTest.php | 2 +- .../Core/Traits/CreatesRequestHandler.php | 2 +- test/Unit/GuzzleClientFactoryTest.php | 45 ++ test/Unit/ZammadClientTest.php | 132 +--- 53 files changed, 670 insertions(+), 1189 deletions(-) delete mode 100644 .opencode/plans/refactoring-plan.md create mode 100644 src/Core/Contracts/ClientFactoryInterface.php rename src/Core/{ => Repository}/AbstractRepository.php (93%) rename src/Core/{ => Repository}/DtoHydrator.php (98%) rename src/Core/{ => Repository}/PaginatedList.php (96%) rename src/Core/{ => Repository}/RepositoryRegistry.php (98%) rename src/Core/{ => Repository}/Resource.php (99%) rename src/Core/{ => Repository}/ResponseParser.php (96%) create mode 100644 src/Core/Traits/RepositoryAccessors.php rename src/Core/{ => Transport}/HttpPageFetcher.php (89%) create mode 100644 src/Core/Transport/ImpersonationHandler.php rename src/Core/{ => Transport}/RequestHandler.php (89%) rename src/Core/{ => Transport}/RetryAfterMiddleware.php (98%) create mode 100644 src/Factory/GuzzleClientFactory.php create mode 100644 test/Unit/Core/ImpersonationHandlerTest.php create mode 100644 test/Unit/GuzzleClientFactoryTest.php diff --git a/.gitignore b/.gitignore index 641b94b..1e9d433 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /.phpunit.cache/ /composer.lock /build/ +/.opencode/ +/playground.php diff --git a/.opencode/plans/refactoring-plan.md b/.opencode/plans/refactoring-plan.md deleted file mode 100644 index 15c4812..0000000 --- a/.opencode/plans/refactoring-plan.md +++ /dev/null @@ -1,589 +0,0 @@ -# Refactoring Plan: `ZammadClient` in drei Rollen zerlegen - -## 1. Motivation - -Die aktuelle `ZammadClient`-Klasse vereint mehrere widerspruchliche Zustandigkeiten in einer Datei: - -- **`RepositoryRegistry` behauptet "single source of truth"** fur Path- und DTO-Mapping — wird aber durch `ZammadClient::aliasMap()` dupliziert. -- **`createClient()` akzeptiert PSR-18-Theorie** (beliebiger `ClientInterface`) — hardcodet aber `new GuzzleClient()` und `new HttpFactory()`. -- **`aliasMap()` / `resolveAlias()` / `__call()`** sind bereits als deprecated markiert, aber noch im Client eingebettet. -- **Impersonation-Methoden** (`setOnBehalfOfUser`, `performOnBehalfOf`, etc.) gehoren als Transport-Concern in den `RequestHandler`, nicht in den Client. -- **`getListKey()`** ist in allen 10 Repository-Implementierungen identisch mit `$this->resourcePath` — 10-fache Redundanz. - -Ziel: **Eine Klasse = eine Verantwortung.** - ---- - -## 2. Architektur-Ubersicht - -### Vorher - -``` -ZammadClient (6 Rollen in einer Klasse) - ├── Factory-Methoden → withToken, withOAuth2, withBasicAuth, withClient - ├── Guzzle-Wiring → createClient, normalizeUrl - ├── Repository-Locator → repo, repository, $repos - ├── Alias-Auflosung → __call, aliasMap, resolveAlias [deprecated] - ├── Impersonation → setOnBehalfOf, unsetOnBehalfOf, performOnBehalfOf - └── Handler-Zugriff → getHandler -``` - -### Nachher - -``` -ClientFactory (Guzzle-Wiring + Factory-Methoden) - ├── withToken, withOAuth2, withBasicAuth → hardcoden Guzzle - ├── withClient → PSR-18-agnostisch - ├── createClient (private) → einzige Guzzle-Stelle - └── normalizeUrl (private) - -ZammadClient (schlanker Repository-Locator) - ├── repo, repository, $repos - └── getHandler - -RequestHandlerInterface (Transport) - ├── request, get, post, put, delete, getRaw, getLastResponse - ├── setOnBehalfOfUser, getOnBehalfOfUser - └── performOnBehalfOf [NEU: von ZammadClient hierher] - -AbstractRepository (Basis-Repo) - └── getListKey() → default $this->resourcePath [war vorher 10x identisch implementiert] - -RepositoryRegistry (unverandert — single source of truth) - └── DEFINITIONS: RepoClass → [path, dto] -``` - ---- - -## 3. Detaillierte Anderungen - -### 3.1 NEU: `src/ClientFactory.php` - -```php -logger ?? new NullLogger(), - maxRetries: $config->maxRetries, - ); - - return new ZammadClient($handler); - } - - private static function normalizeUrl(string $url): string - { - $url = rtrim($url, '/'); - - if (!str_contains($url, '/api/')) { - $url .= '/api/v1'; - } - - return $url; - } - - private static function createClient( - string $url, - ConnectionConfig $config, - string $authHeader, - ): ZammadClient { - $url = self::normalizeUrl($url); - - $headers = [ - 'User-Agent' => self::USER_AGENT, - 'Authorization' => $authHeader, - ]; - - $httpClient = new GuzzleClient([ - 'headers' => $headers, - 'verify' => $config->verifySsl, - 'timeout' => $config->timeout, - 'connect_timeout' => $config->connectTimeout, - 'allow_redirects' => false, - ]); - - $handler = new RequestHandler( - $httpClient, - new HttpFactory(), - $url, - logger: $config->logger ?? new NullLogger(), - maxRetries: $config->maxRetries, - ); - - return new ZammadClient($handler); - } -} -``` - -- **Wichtig**: `createClient()` ist der **einzige Ort im gesamten Codebase**, der Guzzle direkt instantiiert (`new GuzzleClient`, `new HttpFactory`). -- Die Klasse ist `final`, hat nur statische Methoden, keine Properties — reine Factory. -- Alle Ruckgabetypen sind `ZammadClient`. - -### 3.2 MODIFY: `src/ZammadClient.php` - -**Entfernt (ersatzlos oder verschoben):** - -| Methode / Property | Ziel | Grund | -|---|---|---| -| `USER_AGENT` const | `ClientFactory` | Gehort zur Factory | -| `withToken()` | `ClientFactory` | Guzzle-Wiring | -| `withOAuth2()` | `ClientFactory` | Guzzle-Wiring | -| `withBasicAuth()` | `ClientFactory` | Guzzle-Wiring | -| `withClient()` | `ClientFactory` | Gehort konzeptuell zur Factory | -| `createClient()` | `ClientFactory` | Guzzle-Wiring | -| `normalizeUrl()` | `ClientFactory` | URL-Normalisierung ist Factory-Concern | -| `__call()` | **geloscht** | Deprecated; wird in v4.0 entfernt | -| `aliasMap()` | **geloscht** | Deprecated | -| `resolveAlias()` | **geloscht** | Deprecated | -| `setOnBehalfOfUser()` | **geloscht** (war bereits auf Handler) | Transport-Concern | -| `unsetOnBehalfOfUser()` | **geloscht** (war bereits auf Handler) | Transport-Concern | -| `performOnBehalfOf()` | `RequestHandler` (siehe 3.3) | Transport-Concern | - -Entfernte Imports: -- `GuzzleHttp\Client as GuzzleClient` -- `GuzzleHttp\Psr7\HttpFactory` -- `InvalidArgumentException` (nur fur `__call` benotigt) -- `Psr\Http\Client\ClientInterface` -- `Psr\Http\Message\RequestFactoryInterface` -- `Psr\Http\Message\StreamFactoryInterface` -- `Psr\Log\NullLogger` -- `ZammadAPIClient\Core\ConnectionConfig` -- `ZammadAPIClient\Core\Contracts\DTOInterface` - -**Bleibt (unverandert):** - -```php - */ - private array $repos = []; - - public function __construct( - private RequestHandlerInterface $handler, - ) { - } - - public function getHandler(): RequestHandlerInterface - { - return $this->handler; - } - - /** - * @template T of AbstractRepository - * @param class-string $repositoryClass - * @return T - */ - public function repo(string $repositoryClass): AbstractRepository - { - $definition = RepositoryRegistry::definition($repositoryClass); - - return $this->repository($repositoryClass, $definition['path'], $definition['dto']); - } - - /** - * @template T of AbstractRepository - * @param class-string $repoClass - * @param class-string<\ZammadAPIClient\Core\Contracts\DTOInterface> $dtoClass - * @return T - */ - private function repository(string $repoClass, string $path, string $dtoClass): object - { - if (!isset($this->repos[$repoClass])) { - $this->repos[$repoClass] = new $repoClass($this->handler, $path, $dtoClass); - } - - /** @var T $repo */ - $repo = $this->repos[$repoClass]; - - return $repo; - } -} -``` - -**Ergebnis**: ~50 Zeilen (vorher ~210 Zeilen). Die Klasse macht genau eine Sache: `repo()` und `getHandler()`. - -### 3.3 MODIFY: `performOnBehalfOf()` auf `RequestHandler` verschieben - -#### 3.3a: `src/Core/Contracts/RequestHandlerInterface.php` - -**Hinzufugen:** - -```php -/** - * Executes a closure with a temporary impersonation header. - * - * The header is set before the callback and restored afterwards. - * - * @param int|string $userId User ID, login, or email to impersonate. - * @param callable $callback Closure to execute while impersonating. - * @return mixed Return value of the callback. - */ -public function performOnBehalfOf(int|string $userId, callable $callback): mixed; -``` - -Vorhandene `setOnBehalfOfUser` und `getOnBehalfOfUser` bleiben unverandert. - -#### 3.3b: `src/Core/RequestHandler.php` - -**Implementierung hinzufugen:** - -```php -public function performOnBehalfOf(int|string $userId, callable $callback): mixed -{ - $previous = $this->onBehalfOfUser; - - $this->onBehalfOfUser = $userId; - - try { - return $callback(); - } finally { - $this->onBehalfOfUser = $previous; - } -} -``` - -**Hinweis**: Anders als in `ZammadClient` wird hier **kein Argument** an den Callback ubergeben. Der alte Code reichte `$this` (den Client), aber das war fur die tatsachliche Nutzung irrelevant — alle aktuellen Aufrufe verwenden `use`-Closures. - -### 3.4 MODIFY: `getListKey()` in `AbstractRepository` als Default - -Datei: `src/Core/AbstractRepository.php` - -**Anderung**: Aus `abstract` wird eine konkrete Methode mit Default: - -```php -// Vorher (abstract): -abstract protected function getListKey(): string; - -// Nachher (konkret mit Default): -protected function getListKey(): string -{ - return $this->resourcePath; -} -``` - -**Begrundung**: In allen 10 Repos ist `getListKey()` identisch mit `$this->resourcePath`. Die Subklassen-Uberschreibungen sind reine Redundanz. - -### 3.5 MODIFY: `getListKey()` aus allen Repository-Klassen entfernen - -Diese Methode wird aus allen 10 Repository-Dateien ersatzlos gestrichen: - -| Datei | Zu entfernender Code | -|---|---| -| `src/Endpoints/Tickets/TicketRepository.php` | `protected function getListKey(): string { return 'tickets'; }` | -| `src/Endpoints/Users/UserRepository.php` | `protected function getListKey(): string { return 'users'; }` | -| `src/Endpoints/Groups/GroupRepository.php` | `protected function getListKey(): string { return 'groups'; }` | -| `src/Endpoints/Organizations/OrganizationRepository.php` | `protected function getListKey(): string { return 'organizations'; }` | -| `src/Endpoints/Links/LinkRepository.php` | `protected function getListKey(): string { return 'links'; }` | -| `src/Endpoints/TicketArticles/TicketArticleRepository.php` | `protected function getListKey(): string { return 'ticket_articles'; }` | -| `src/Endpoints/TicketStates/TicketStateRepository.php` | `protected function getListKey(): string { return 'ticket_states'; }` | -| `src/Endpoints/TicketPriorities/TicketPriorityRepository.php` | `protected function getListKey(): string { return 'ticket_priorities'; }` | -| `src/Endpoints/Tags/TagRepository.php` | `protected function getListKey(): string { return 'tags'; }` | -| `src/Endpoints/TextModules/TextModuleRepository.php` | `protected function getListKey(): string { return 'text_modules'; }` | - -### 3.6 NO CHANGES: `src/Core/Contracts/ClientInterface.php` - -Das Interface enthalt bereits nur `repo()` und `getHandler()` — es spiegelt bereits das Ziel-Design. - -### 3.7 NO CHANGES: `src/Core/RepositoryRegistry.php` - -Die Registry bleibt als "single source of truth" unverandert. Mit dem Wegfall von `aliasMap()` im Client gibt es keine Duplikation mehr. - ---- - -## 4. Test-Anderungen - -### 4.1 NEU: `test/Unit/ClientFactoryTest.php` - -Enthalt die aus `ZammadClientTest` verschobenen Tests: - -| Test-Methode | Beschreibung | -|---|---| -| `testWithClientUsesInjectedHttpClient` | Stellt sicher, dass ein injizierter PSR-18-Client verwendet wird | -| `testWithClientThrowsWhenFactoryNotStreamFactory` | Validiert den StreamFactory-Guard | - -### 4.2 MODIFY: `test/Unit/ZammadClientTest.php` - -**Bleiben (unverandert):** - -- `testRepoReturnsMemoizedRepositoryInstance` -- `testRepoThrowsForUnknownRepositoryClass` - -**Entfernt (weil getestete Funktion nicht mehr existiert):** - -- `testCallResolvesTicketRepository` — `__call` geloscht -- `testCallResolvesUserRepository` — `__call` geloscht -- `testCallMemoizesRepository` — `__call` geloscht -- `testCallThrowsForUnknownResource` — `__call` geloscht -- `testCallResolvesUnderscoreResources` — `__call` geloscht -- `testSetOnBehalfOfUserDelegatesToHandler` — Methode nicht mehr auf Client -- `testUnsetOnBehalfOfUserDelegatesToHandler` — Methode nicht mehr auf Client -- `testPerformOnBehalfOfExecutesCallbackAndResets` — Methode nicht mehr auf Client -- `testPerformOnBehalfOfResetsOnException` — Methode nicht mehr auf Client - -**Verschoben nach `ClientFactoryTest`:** - -- `testWithClientUsesInjectedHttpClient` -- `testWithClientThrowsWhenFactoryNotStreamFactory` - -Nur noch 2 Tests — der Client ist jetzt so dunn, dass wenig zu testen bleibt. Alle anderen Concerns werden an anderer Stelle getestet. - -### 4.3 MODIFY: `test/Unit/Core/RequestHandlerTest.php` - -Neue Tests fur `performOnBehalfOf()`: - -| Test-Methode | Beschreibung | -|---|---| -| `testPerformOnBehalfOfExecutesCallbackAndResets` | Setzt Impersonation, fuhrt Callback aus, stellt Zustand wieder her | -| `testPerformOnBehalfOfResetsOnException` | Stellt Zustand auch bei Exception im Callback wieder her | -| `testPerformOnBehalfOfReturnsCallbackValue` | Ruckgabewert des Callbacks wird durchgereicht | - -### 4.4 MODIFY: `test/Integration/Traits/CreatesZammadClient.php` - -```php -// Zeilen 30, 34: Vorher -return ZammadClient::withToken($url, $token); -return ZammadClient::withBasicAuth($url, $user, $pass); -// Nachher -return ClientFactory::withToken($url, $token); -return ClientFactory::withBasicAuth($url, $user, $pass); -``` - -Import hinzufugen: `use ZammadAPIClient\ClientFactory;` - ---- - -## 5. Bridge-Anderungen - -### 5.1 `src/Bridge/LaravelServiceProvider.php` - -```php -// Zeile 91: Vorher -return ZammadClient::withToken($url, $token); -// Nachher -return ClientFactory::withToken($url, $token); -``` - -Import hinzufugen: `use ZammadAPIClient\ClientFactory;` - -### 5.2 `src/Bridge/SymfonyBundle.php` - -```php -// Zeile 86: Vorher -$client = ZammadClient::withToken($url, $token); -// Nachher -$client = ClientFactory::withToken($url, $token); -``` - -Import hinzufugen: `use ZammadAPIClient\ClientFactory;` - ---- - -## 6. Dokumentation - -### 6.1 `examples/cookbook.php` - -```php -// Zeilen 29-30: Vorher -$client = $token !== '' - ? ZammadClient::withToken($url, $token) - : ZammadClient::withBasicAuth($url, $user, $pass); -// Nachher -$client = $token !== '' - ? ClientFactory::withToken($url, $token) - : ClientFactory::withBasicAuth($url, $user, $pass); -``` - -Import: `use ZammadAPIClient\ClientFactory;` - -Zeile 129-132 (Impersonation): -```php -// Vorher: -$client->performOnBehalfOf(1, function () use ($repo, $ticketId) { ... }); -// Nachher: -$client->getHandler()->performOnBehalfOf(1, function () use ($repo, $ticketId) { ... }); -``` - -### 6.2 `README.md` - -Ersetze `ZammadClient::withToken` → `ClientFactory::withToken` an allen 10 Stellen. Quick-Start-Beispiel: - -```php -use ZammadAPIClient\ClientFactory; -use ZammadAPIClient\Endpoints\Tickets\TicketDTO; -use ZammadAPIClient\Endpoints\Tickets\TicketRepository; - -$client = ClientFactory::withToken('https://zammad.example', 'your-token'); -``` - -### 6.3 `docs/migration-v3.md` - -- Zeile 7, 44: `ZammadClient::withToken` → `ClientFactory::withToken` - -### 6.4 `docs/migration-v3-examples.md` - -- Zeilen 19, 38-40: `ZammadClient::with*` → `ClientFactory::with*` - -### 6.5 `docs/alternative-clients.md` - -- Zeile 72: `ZammadClient::withToken()` → `ClientFactory::withToken()` - -### 6.6 `CHANGELOG.md` - -Neue Eintrage unter `## [3.0.0] — unreleased`: - -```markdown -### Changed -- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` / `withClient()` → `ClientFactory::with*()` -- **Breaking:** `ZammadClient::performOnBehalfOf()` → `$client->getHandler()->performOnBehalfOf()` -- `getListKey()` in Repository-Klassen nicht mehr abstrakt; Default ist `$this->resourcePath` - -### Removed -- **Breaking:** Magic resource accessor (`$client->ticket()`) — ersatzlos entfernt. Nutze `$client->repo(TicketRepository::class)`. -- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` — nutze `$client->getHandler()->setOnBehalfOfUser()` -- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, jetzt entfernt -``` - ---- - -## 7. Ausfuhrungsreihenfolge - -| Schritt | Beschreibung | Dateien | Risiko | -|---|---|---|---| -| 1 | `ClientFactory` erstellen (neue Datei) | 1 new | Kein | -| 2 | `performOnBehalfOf` zu `RequestHandlerInterface` + `RequestHandler` hinzufugen | 2 modify | Gering | -| 3 | `ZammadClient` ausdunnen (factories, aliases, impersonation entfernen) | 1 modify | Mittel | -| 4 | `getListKey()` Default in `AbstractRepository` | 1 modify | Gering | -| 5 | `getListKey()` aus allen 10 Repos entfernen | 10 modify | Gering | -| 6 | `ClientFactoryTest` erstellen | 1 new | Kein | -| 7 | `ZammadClientTest` anpassen | 1 modify | Gering | -| 8 | `RequestHandlerTest` um `performOnBehalfOf`-Tests erganzen | 1 modify | Kein | -| 9 | Framework Bridges updaten | 2 modify | Gering | -| 10 | `CreatesZammadClient` Trait updaten | 1 modify | Gering | -| 11 | `cookbook.php` updaten | 1 modify | Kein | -| 12 | Dokumentation updaten | ~6 modify | Kein | -| 13 | `phpstan analyse src/ --level=max` | — | Prufung | -| 14 | `phpcs` | — | Prufung | -| 15 | `phpunit --testsuite=unit` | — | Prufung | -| 16 | `phpunit --testsuite=integration` | — | Prufung | - ---- - -## 8. Migration Notes fur Endnutzer - -```php -// ── Client-Erstellung ────────────────────────────────── -// VORHER: -$client = ZammadClient::withToken($url, $token); -$client = ZammadClient::withBasicAuth($url, $user, $pass); -$client = ZammadClient::withOAuth2($url, $token); -$client = ZammadClient::withClient($http, $factory, $url); - -// NACHHER: -$client = ClientFactory::withToken($url, $token); -$client = ClientFactory::withBasicAuth($url, $user, $pass); -$client = ClientFactory::withOAuth2($url, $token); -$client = ClientFactory::withClient($http, $factory, $url); - -// ── Impersonation ───────────────────────────────────── -// VORHER: -$client->setOnBehalfOfUser(1); -$client->unsetOnBehalfOfUser(); -$client->performOnBehalfOf(1, fn() => doSomething()); - -// NACHHER: -$client->getHandler()->setOnBehalfOfUser(1); -$client->getHandler()->setOnBehalfOfUser(null); -$client->getHandler()->performOnBehalfOf(1, fn() => doSomething()); - -// ── Magic Resource Accessor ─────────────────────────── -// VORHER: $client->ticket()->find(1); // deprecated -// NACHHER: $client->repo(TicketRepository::class)->find(1); // einziger Weg -``` - -**Keine Anderungen:** - -- `$client->repo(SomeRepository::class)` — unverandert -- `$client->getHandler()->get(...)` / `post(...)` / ... — unverandert -- Alle Repository-Klassen und DTOs — unverandert - ---- - -## 9. Phase 2 (nicht Teil dieses PRs) - -1. **Framework Bridges: Repositories im Container registrieren** — statt nur `ZammadClient`, jedes Repository als eigenen Service verfugbar machen. -2. **`RepositoryRegistry` per PHP-Attribut** — `#[Resource('tickets', TicketDTO::class)]` auf der Repo-Klasse statt zentraler Map. -3. **`getListKey()` ganzlich entfernen** — `$this->resourcePath` direkt verwenden. diff --git a/CHANGELOG.md b/CHANGELOG.md index b152672..0b6c750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,19 +13,29 @@ - OpenAPI schema validation in integration tests - Framework bridges for Laravel and Symfony - `TicketArticleType` enum for article channel types +- `ClientFactoryInterface` interface + `GuzzleClientFactory` — `createHandler()` baut den `RequestHandler` +- `ImpersonationHandler` — stateless decorator fur API-Impersonation +- `ImpersonationHandler` — scoped via `new ZammadClient(new ImpersonationHandler($handler, $userId))` ### Changed - **Breaking:** PHP >= 8.1 required +- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` → `new ZammadClient(GuzzleClientFactory::with*())` (Namespace `ZammadAPIClient\Factory`). + Non-Guzzle via `new ZammadClient(new RequestHandler($psr18Client, $psr17Factory, $url))`. +- **Breaking:** `ZammadClient::withClient()` entfernt; `ZammadClient` constructor akzeptiert `RequestHandlerInterface|ClientFactoryInterface` +- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` / `onBehalfOf()` / `performOnBehalfOf()` entfernt. + Impersonation via `new ZammadClient(new ImpersonationHandler($handler, $userId))`. +- **Breaking:** `RequestHandlerInterface::setOnBehalfOfUser()` / `getOnBehalfOfUser()` entfernt. + `RequestHandler` halt keinen Impersonation-State mehr. +- **Breaking:** `RequestHandlerInterface::getRaw()` Signatur um `$headers`-Parameter erweitert - **Breaking:** `Client` class replaced by `ZammadClient` with repository accessors - **Breaking:** Array return values replaced by typed DTOs - **Breaking:** Guzzle used as default transport; `withClient()` supports any PSR-18 client - -### Deprecated -- Magic resource accessor (`$client->ticket()`) — triggers `E_USER_DEPRECATED`. Use `$client->repo(TicketRepository::class)` instead. Removed in v4.0. +- `getListKey()` in Repository-Klassen nicht mehr abstrakt; Default ist `$this->resourcePath` ### Removed -- `ResourceType` constants — use typed repository methods -- `AbstractResource` 640-line monolith — replaced by individual repositories +- Magic resource accessor (`$client->ticket()`) — ersatzlos entfernt. Nutze `$client->repo(TicketRepository::class)`. +- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, jetzt entfernt +- Shared mutable Impersonation-State aus `RequestHandler` entfernt --- diff --git a/examples/cookbook.php b/examples/cookbook.php index 56e2866..769a321 100644 --- a/examples/cookbook.php +++ b/examples/cookbook.php @@ -16,8 +16,8 @@ use ZammadAPIClient\Endpoints\TicketArticles\TicketArticleType; use ZammadAPIClient\Endpoints\Tickets\TicketDTO; -use ZammadAPIClient\Endpoints\Tickets\TicketRepository; use ZammadAPIClient\Exceptions\NotFoundException; +use ZammadAPIClient\Factory\GuzzleClientFactory; use ZammadAPIClient\ZammadClient; $url = getenv('ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL') ?: 'http://localhost:3000'; @@ -26,12 +26,13 @@ $pass = getenv('ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD') ?: 'test'; $client = $token !== '' - ? ZammadClient::withToken($url, $token) - : ZammadClient::withBasicAuth($url, $user, $pass); + ? new ZammadClient(GuzzleClientFactory::withToken($url, $token)) + : new ZammadClient(GuzzleClientFactory::withBasicAuth($url, $user, $pass)); echo "✓ Client connected to {$url}\n"; -$repo = $client->repo(TicketRepository::class); +// Typed shortcut — `$client->ticket()` returns TicketRepository +$repo = $client->ticket(); // ── Caching reference data ────────────────────────────────────── // States and priorities rarely change. The simple static cache below @@ -45,10 +46,10 @@ function memoize(string $key, callable $fn): mixed { } $priorities = memoize('priorities', fn() => - [...$client->repo(\ZammadAPIClient\Endpoints\TicketPriorities\TicketPriorityRepository::class)->all()] + [...$client->ticketPriority()->all()] ); $states = memoize('states', fn() => - [...$client->repo(\ZammadAPIClient\Endpoints\TicketStates\TicketStateRepository::class)->all()] + [...$client->ticketState()->all()] ); $normalPriorityId = null; @@ -126,10 +127,10 @@ function memoize(string $key, callable $fn): mixed { echo "✓ Ticket #{$created->id} deleted\n"; // ── Recipe 8: On-Behalf-Of impersonation ──────────────────────── -$client->performOnBehalfOf(1, function () use ($repo, $ticketId) { - echo " (acting as user #1) Ticket #{$ticketId} title: " - . $repo->find($ticketId)->title . "\n"; -}); +$imp = new \ZammadAPIClient\Core\Transport\ImpersonationHandler($client->getHandler(), 1); +$scoped = new ZammadClient($imp); +echo " (acting as user #1) Ticket #{$ticketId} title: " + . $scoped->ticket()->find($ticketId)->title . "\n"; echo "✓ Impersonation complete\n"; // ── Recipe 9: Search ──────────────────────────────────────────── diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 231d7e0..0f5cb84 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,7 +7,3 @@ parameters: excludePaths: # Bridge classes require Laravel/Symfony packages at runtime only. - src/Bridge/ - ignoreErrors: - - - message: '#return type with generic class.*does not specify its types#' - path: src/ZammadClient.php diff --git a/src/Bridge/LaravelServiceProvider.php b/src/Bridge/LaravelServiceProvider.php index 6188c61..77020e1 100644 --- a/src/Bridge/LaravelServiceProvider.php +++ b/src/Bridge/LaravelServiceProvider.php @@ -5,6 +5,7 @@ namespace ZammadAPIClient\Bridge; use Illuminate\Support\ServiceProvider; +use ZammadAPIClient\Factory\GuzzleClientFactory; use ZammadAPIClient\ZammadClient; /** @@ -88,7 +89,9 @@ public function register(): void $url = config('zammad.url') ?: env('ZAMMAD_URL', 'http://127.0.0.1:8098'); $token = config('zammad.token') ?: env('ZAMMAD_TOKEN', ''); - return ZammadClient::withToken($url, $token); + return new ZammadClient( + GuzzleClientFactory::withToken($url, $token), + ); }); } } diff --git a/src/Bridge/SymfonyBundle.php b/src/Bridge/SymfonyBundle.php index 8d62eef..36934b9 100644 --- a/src/Bridge/SymfonyBundle.php +++ b/src/Bridge/SymfonyBundle.php @@ -7,6 +7,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; +use ZammadAPIClient\Factory\GuzzleClientFactory; use ZammadAPIClient\ZammadClient; /** @@ -83,7 +84,9 @@ public function load(array $configs, ContainerBuilder $container): void $url = $resolved['url'] ?? (string) ($_ENV['ZAMMAD_URL'] ?? 'http://127.0.0.1:8098/api/v1'); $token = $resolved['token'] ?? (string) ($_ENV['ZAMMAD_TOKEN'] ?? ''); - $client = ZammadClient::withToken($url, $token); + $client = new ZammadClient( + GuzzleClientFactory::withToken($url, $token), + ); $container->set(ZammadClient::class, $client); $container->setAlias('zammad_client', ZammadClient::class); $container->registerForAutoconfiguration(ZammadClient::class)->setAutowired(true); diff --git a/src/Core/Cast.php b/src/Core/Cast.php index 5e32c87..5d877e2 100644 --- a/src/Core/Cast.php +++ b/src/Core/Cast.php @@ -19,7 +19,7 @@ * a typed value or null. They never throw — a missing or uncastable value * always results in null or the supplied default. * - * Used exclusively by {@see \ZammadAPIClient\Core\DtoHydrator}. + * Used exclusively by {@see \ZammadAPIClient\Core\Repository\DtoHydrator}. */ final class Cast { diff --git a/src/Core/Contracts/ClientFactoryInterface.php b/src/Core/Contracts/ClientFactoryInterface.php new file mode 100644 index 0000000..fc392dc --- /dev/null +++ b/src/Core/Contracts/ClientFactoryInterface.php @@ -0,0 +1,12 @@ + $query URL query parameters. + * @param array $query URL query parameters. + * @param array $headers Additional HTTP headers to send. * @throws \ZammadAPIClient\Exceptions\NetworkException On transport failure. */ - public function getRaw(string $uri, array $query = []): string; + public function getRaw(string $uri, array $query = [], array $headers = []): string; /** * Performs a GET request and returns the decoded JSON body. @@ -94,18 +95,4 @@ public function delete(string $uri): array; * Useful for inspecting response headers after a repository call. */ public function getLastResponse(): ?ResponseInterface; - - /** - * Sets or clears the user identifier for API impersonation. - * - * When non-null the value is forwarded as the `From` HTTP header, - * causing Zammad to execute the request as the given user. - * The value may be a user ID, login, or email. Pass null to - * remove impersonation. - * - * @see https://docs.zammad.org/en/latest/api/intro.html#actions-on-behalf-of-other-users - */ - public function setOnBehalfOfUser(int|string|null $userId): void; - - public function getOnBehalfOfUser(): int|string|null; } diff --git a/src/Core/AbstractRepository.php b/src/Core/Repository/AbstractRepository.php similarity index 93% rename from src/Core/AbstractRepository.php rename to src/Core/Repository/AbstractRepository.php index 7b6e946..f59975d 100644 --- a/src/Core/AbstractRepository.php +++ b/src/Core/Repository/AbstractRepository.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ZammadAPIClient\Core; +namespace ZammadAPIClient\Core\Repository; use BadMethodCallException; use Generator; @@ -26,7 +26,7 @@ * * All repositories are instantiated via {@see \ZammadAPIClient\ZammadClient::repo()}, * which injects the shared `RequestHandler` and the wiring defined in - * {@see \ZammadAPIClient\Core\RepositoryRegistry::DEFINITIONS}. + * {@see \ZammadAPIClient\Core\Repository\RepositoryRegistry::DEFINITIONS}. * * @template T of DTOInterface * @implements RepositoryInterface @@ -66,10 +66,13 @@ public function getDtoClass(): string * Returns the JSON array key that contains the resource list in paginated responses. * * Zammad's list endpoints wrap results in a keyed array (e.g. `{"tickets": [...], "assets": {...}}`). - * Each endpoint uses a different key; subclasses declare the correct one here so - * {@see self::extractItems()} can locate the items without guessing. + * Each endpoint uses a different key; the default is the resource path. + * Subclasses may override this when the list key differs from the path. */ - abstract protected function getListKey(): string; + protected function getListKey(): string + { + return $this->resourcePath; + } /** * Fetches a single resource by its Zammad-assigned ID. @@ -289,6 +292,20 @@ public function getIterator(): Traversable return $this->all(); } + /** + * Returns the total number of resources via search. + * + * Delegates to `searchList('*')` which fetches the first page + * with `with_total_count=true`. One API call, no memory overhead. + * + * Override this method if the endpoint does not support the + * standard `/search` path (e.g. links, tags). + */ + public function totalCount(): int + { + return $this->searchList('*')->page(1)->getTotalCount() ?? 0; + } + /** * Creates a new resource and returns the server-confirmed DTO. * diff --git a/src/Core/DtoHydrator.php b/src/Core/Repository/DtoHydrator.php similarity index 98% rename from src/Core/DtoHydrator.php rename to src/Core/Repository/DtoHydrator.php index 93144a9..ae19ae7 100644 --- a/src/Core/DtoHydrator.php +++ b/src/Core/Repository/DtoHydrator.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace ZammadAPIClient\Core; +namespace ZammadAPIClient\Core\Repository; use DateTimeImmutable; use ReflectionClass; use ReflectionNamedType; +use ZammadAPIClient\Core\Cast; /** * Type-driven DTO hydration: maps array keys onto constructor parameters using diff --git a/src/Core/PaginatedList.php b/src/Core/Repository/PaginatedList.php similarity index 96% rename from src/Core/PaginatedList.php rename to src/Core/Repository/PaginatedList.php index 4ec4ee5..f4a6a16 100644 --- a/src/Core/PaginatedList.php +++ b/src/Core/Repository/PaginatedList.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ZammadAPIClient\Core; +namespace ZammadAPIClient\Core\Repository; use ArrayAccess; use Countable; @@ -10,6 +10,7 @@ use ZammadAPIClient\Core\Contracts\DTOInterface; use ZammadAPIClient\Core\Contracts\PageFetcherInterface; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; +use ZammadAPIClient\Core\Transport\HttpPageFetcher; /** * @template T of DTOInterface @@ -150,6 +151,10 @@ public function count(): int */ public function getTotalCount(): ?int { + if ($this->totalCount === null) { + $this->fetchPage($this->currentPage); + } + return $this->totalCount; } diff --git a/src/Core/RepositoryRegistry.php b/src/Core/Repository/RepositoryRegistry.php similarity index 98% rename from src/Core/RepositoryRegistry.php rename to src/Core/Repository/RepositoryRegistry.php index 45fbfbd..136220f 100644 --- a/src/Core/RepositoryRegistry.php +++ b/src/Core/Repository/RepositoryRegistry.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ZammadAPIClient\Core; +namespace ZammadAPIClient\Core\Repository; use InvalidArgumentException; use ZammadAPIClient\Core\Contracts\DTOInterface; diff --git a/src/Core/Resource.php b/src/Core/Repository/Resource.php similarity index 99% rename from src/Core/Resource.php rename to src/Core/Repository/Resource.php index 7d94af8..47da0e4 100644 --- a/src/Core/Resource.php +++ b/src/Core/Repository/Resource.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ZammadAPIClient\Core; +namespace ZammadAPIClient\Core\Repository; use ZammadAPIClient\Core\Contracts\DTOInterface; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; diff --git a/src/Core/ResponseParser.php b/src/Core/Repository/ResponseParser.php similarity index 96% rename from src/Core/ResponseParser.php rename to src/Core/Repository/ResponseParser.php index 599f588..6e0b234 100644 --- a/src/Core/ResponseParser.php +++ b/src/Core/Repository/ResponseParser.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ZammadAPIClient\Core; +namespace ZammadAPIClient\Core\Repository; /** * Extracts and normalises DTO items from raw Zammad API response arrays. diff --git a/src/Core/Traits/HydratesFromArray.php b/src/Core/Traits/HydratesFromArray.php index bf7d74f..29e1a05 100644 --- a/src/Core/Traits/HydratesFromArray.php +++ b/src/Core/Traits/HydratesFromArray.php @@ -4,12 +4,12 @@ namespace ZammadAPIClient\Core\Traits; -use ZammadAPIClient\Core\DtoHydrator; +use ZammadAPIClient\Core\Repository\DtoHydrator; /** * Provides a reflection-based `fromArray()` implementation for DTOs. * - * Delegates to {@see \ZammadAPIClient\Core\DtoHydrator::hydrate()}, which + * Delegates to {@see \ZammadAPIClient\Core\Repository\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 @@ -18,7 +18,7 @@ * When the API field name differs from the constructor parameter, or when * a fallback key must be tried (e.g. `owner_id` vs `assigned_to_id`), * override `fromArray()` in the specific DTO, normalise the array, and then - * delegate to {@see \ZammadAPIClient\Core\DtoHydrator::hydrate()}. + * delegate to {@see \ZammadAPIClient\Core\Repository\DtoHydrator::hydrate()}. */ trait HydratesFromArray { diff --git a/src/Core/Traits/RepositoryAccessors.php b/src/Core/Traits/RepositoryAccessors.php new file mode 100644 index 0000000..dfc02c4 --- /dev/null +++ b/src/Core/Traits/RepositoryAccessors.php @@ -0,0 +1,72 @@ +repo(TicketRepository::class); + } + + public function user(): UserRepository + { + return $this->repo(UserRepository::class); + } + + public function organization(): OrganizationRepository + { + return $this->repo(OrganizationRepository::class); + } + + public function group(): GroupRepository + { + return $this->repo(GroupRepository::class); + } + + public function ticketArticle(): TicketArticleRepository + { + return $this->repo(TicketArticleRepository::class); + } + + public function ticketState(): TicketStateRepository + { + return $this->repo(TicketStateRepository::class); + } + + public function ticketPriority(): TicketPriorityRepository + { + return $this->repo(TicketPriorityRepository::class); + } + + public function tag(): TagRepository + { + return $this->repo(TagRepository::class); + } + + public function textModule(): TextModuleRepository + { + return $this->repo(TextModuleRepository::class); + } + + public function link(): LinkRepository + { + return $this->repo(LinkRepository::class); + } +} diff --git a/src/Core/HttpPageFetcher.php b/src/Core/Transport/HttpPageFetcher.php similarity index 89% rename from src/Core/HttpPageFetcher.php rename to src/Core/Transport/HttpPageFetcher.php index f692fb1..6ea6d00 100644 --- a/src/Core/HttpPageFetcher.php +++ b/src/Core/Transport/HttpPageFetcher.php @@ -2,10 +2,11 @@ declare(strict_types=1); -namespace ZammadAPIClient\Core; +namespace ZammadAPIClient\Core\Transport; use ZammadAPIClient\Core\Contracts\DTOInterface; use ZammadAPIClient\Core\Contracts\PageFetcherInterface; +use ZammadAPIClient\Core\Repository\ResponseParser; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; /** @@ -64,8 +65,8 @@ public function fetch(int $page, int $perPage, array $baseQuery): array */ private function extractSearchResults(array $data): array { - /** @phpstan-ignore cast.int */ - $total = (int) ($data['total_count'] ?? 0); + $raw = $data['total_count'] ?? 0; + $total = is_numeric($raw) ? (int) $raw : 0; $items = $data['records'] ?? []; return [ @@ -76,15 +77,17 @@ private function extractSearchResults(array $data): array /** * @param array $data - * @return array{items: list, total_count: null} + * @return array{items: list, total_count: ?int} */ private function extractIndexResults(array $data): array { $listKey = $this->listKey ?? $this->inferListKey(); + $raw = $data['total_count'] ?? null; + $total = is_numeric($raw) ? (int) $raw : null; return [ 'items' => $this->hydrateItems(ResponseParser::extractItems($data, $listKey)), - 'total_count' => null, + 'total_count' => $total, ]; } diff --git a/src/Core/Transport/ImpersonationHandler.php b/src/Core/Transport/ImpersonationHandler.php new file mode 100644 index 0000000..58a7bed --- /dev/null +++ b/src/Core/Transport/ImpersonationHandler.php @@ -0,0 +1,73 @@ +userId; + + return $this->inner->request($method, $uri, $options); + } + + public function get(string $uri, array $query = []): array + { + if (!empty($query)) { + $uri .= '?' . http_build_query($query); + } + + return $this->request('GET', $uri); + } + + public function post(string $uri, array $body = []): array + { + return $this->request('POST', $uri, [ + 'headers' => ['Content-Type' => 'application/json'], + 'body' => json_encode($body, JSON_THROW_ON_ERROR), + ]); + } + + public function put(string $uri, array $body = []): array + { + return $this->request('PUT', $uri, [ + 'headers' => ['Content-Type' => 'application/json'], + 'body' => json_encode($body, JSON_THROW_ON_ERROR), + ]); + } + + public function delete(string $uri): array + { + return $this->request('DELETE', $uri); + } + + public function getRaw(string $uri, array $query = [], array $headers = []): string + { + $headers['From'] = (string) $this->userId; + + return $this->inner->getRaw($uri, $query, $headers); + } + + public function getLastResponse(): ?ResponseInterface + { + return $this->inner->getLastResponse(); + } +} diff --git a/src/Core/RequestHandler.php b/src/Core/Transport/RequestHandler.php similarity index 89% rename from src/Core/RequestHandler.php rename to src/Core/Transport/RequestHandler.php index cb19ffc..bfe2d72 100644 --- a/src/Core/RequestHandler.php +++ b/src/Core/Transport/RequestHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ZammadAPIClient\Core; +namespace ZammadAPIClient\Core\Transport; use InvalidArgumentException; use Psr\Http\Client\ClientExceptionInterface; @@ -28,7 +28,6 @@ * * This class is the only place in the library that touches the network. It: * - Prepends $baseUrl to every relative URI. - * - Attaches the `From` header when impersonation is active. * - Serialises PHP arrays as JSON request bodies via PSR-17 stream factories. * - Deserialises JSON responses to plain arrays. * - Maps HTTP error codes to typed domain exceptions before returning @@ -45,7 +44,6 @@ final class RequestHandler implements RequestHandlerInterface private string $baseUrl; private LoggerInterface $logger; private ?ResponseInterface $lastResponse = null; - private int|string|null $onBehalfOfUser = null; /** * @param ClientInterface $httpClient PSR-18 client (any implementation). @@ -86,26 +84,6 @@ public function getLastResponse(): ?ResponseInterface return $this->lastResponse; } - /** - * Activates or deactivates API-level user impersonation. - * - * When $userId is non-null it is forwarded as the `From` HTTP - * header on every subsequent request, causing Zammad to execute actions - * as the given agent. The value may be a user ID, login, or email. - * Pass null to disable impersonation. - * - * @see https://docs.zammad.org/en/latest/api/intro.html#actions-on-behalf-of-other-users - */ - public function setOnBehalfOfUser(int|string|null $userId): void - { - $this->onBehalfOfUser = $userId; - } - - public function getOnBehalfOfUser(): int|string|null - { - return $this->onBehalfOfUser; - } - /** * Dispatches an HTTP request and returns the JSON-decoded body as an array. * @@ -148,15 +126,18 @@ public function request( * Unlike {@see self::get()}, the body is NOT JSON-decoded. Use this for * binary endpoints such as ticket attachment downloads. * - * @param array $query URL query parameters to append. + * @param array $query URL query parameters to append. + * @param array $headers Additional HTTP headers to send. */ - public function getRaw(string $uri, array $query = []): string + public function getRaw(string $uri, array $query = [], array $headers = []): string { if (!empty($query)) { $uri .= '?' . http_build_query($query); } - return (string) $this->dispatch('GET', $uri, [])->getBody(); + $options = !empty($headers) ? ['headers' => $headers] : []; + + return (string) $this->dispatch('GET', $uri, $options)->getBody(); } /** @@ -235,12 +216,6 @@ private function dispatch(string $method, string $uri, array $options): Response $fullUri = $this->baseUrl . '/' . ltrim($uri, '/'); $this->logger->debug("Zammad API request: {$method} {$fullUri}"); - if ($this->onBehalfOfUser !== null) { - $headers = $options['headers'] ?? []; - $options['headers'] = is_array($headers) ? $headers : []; - $options['headers']['From'] = (string) $this->onBehalfOfUser; - } - try { $request = $this->requestFactory->createRequest($method, $fullUri); diff --git a/src/Core/RetryAfterMiddleware.php b/src/Core/Transport/RetryAfterMiddleware.php similarity index 98% rename from src/Core/RetryAfterMiddleware.php rename to src/Core/Transport/RetryAfterMiddleware.php index dcae9be..707fb86 100644 --- a/src/Core/RetryAfterMiddleware.php +++ b/src/Core/Transport/RetryAfterMiddleware.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ZammadAPIClient\Core; +namespace ZammadAPIClient\Core\Transport; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; diff --git a/src/Endpoints/Groups/GroupRepository.php b/src/Endpoints/Groups/GroupRepository.php index 69c9edb..ed433de 100644 --- a/src/Endpoints/Groups/GroupRepository.php +++ b/src/Endpoints/Groups/GroupRepository.php @@ -4,7 +4,7 @@ namespace ZammadAPIClient\Endpoints\Groups; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; use ZammadAPIClient\Core\Contracts\DeletableInterface; /** @@ -18,11 +18,6 @@ */ final class GroupRepository extends AbstractRepository implements DeletableInterface { - protected function getListKey(): string - { - return 'groups'; - } - public function delete(int $id): void { $this->handler->delete("{$this->resourcePath}/{$id}"); diff --git a/src/Endpoints/Links/LinkRepository.php b/src/Endpoints/Links/LinkRepository.php index 28911c3..374fb8b 100644 --- a/src/Endpoints/Links/LinkRepository.php +++ b/src/Endpoints/Links/LinkRepository.php @@ -5,7 +5,7 @@ namespace ZammadAPIClient\Endpoints\Links; use InvalidArgumentException; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; /** * Repository for the `/api/v1/links` endpoint. @@ -23,11 +23,6 @@ */ final class LinkRepository extends AbstractRepository { - protected function getListKey(): string - { - return 'links'; - } - /** * Lists all links for a given Zammad object. * diff --git a/src/Endpoints/Organizations/OrganizationRepository.php b/src/Endpoints/Organizations/OrganizationRepository.php index 4a82e9e..cbc0a2c 100644 --- a/src/Endpoints/Organizations/OrganizationRepository.php +++ b/src/Endpoints/Organizations/OrganizationRepository.php @@ -4,7 +4,7 @@ namespace ZammadAPIClient\Endpoints\Organizations; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; use ZammadAPIClient\Core\Contracts\DeletableInterface; /** @@ -18,14 +18,6 @@ */ final class OrganizationRepository extends AbstractRepository implements DeletableInterface { - /** - * Returns 'organizations' — the JSON array key in Zammad's paginated organization list response. - */ - protected function getListKey(): string - { - return 'organizations'; - } - /** * Bulk-imports organizations from a CSV string. * diff --git a/src/Endpoints/Tags/TagRepository.php b/src/Endpoints/Tags/TagRepository.php index 063681a..00c1267 100644 --- a/src/Endpoints/Tags/TagRepository.php +++ b/src/Endpoints/Tags/TagRepository.php @@ -6,7 +6,7 @@ use Generator; use InvalidArgumentException; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; /** * Repository for the `/api/v1/tags` endpoint. @@ -28,14 +28,6 @@ */ final class TagRepository extends AbstractRepository { - /** - * Returns 'tags' — the JSON array key in Zammad's tag list response. - */ - protected function getListKey(): string - { - return 'tags'; - } - /** * Streams tags for a specific object, paginated. * diff --git a/src/Endpoints/TextModules/TextModuleRepository.php b/src/Endpoints/TextModules/TextModuleRepository.php index b4f9dd0..df1312f 100644 --- a/src/Endpoints/TextModules/TextModuleRepository.php +++ b/src/Endpoints/TextModules/TextModuleRepository.php @@ -4,7 +4,7 @@ namespace ZammadAPIClient\Endpoints\TextModules; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; use ZammadAPIClient\Core\Contracts\DeletableInterface; /** @@ -19,11 +19,6 @@ */ final class TextModuleRepository extends AbstractRepository implements DeletableInterface { - protected function getListKey(): string - { - return 'text_modules'; - } - public function delete(int $id): void { $this->handler->delete("{$this->resourcePath}/{$id}"); diff --git a/src/Endpoints/TicketArticles/TicketArticleRepository.php b/src/Endpoints/TicketArticles/TicketArticleRepository.php index eeff4ee..b51f2dc 100644 --- a/src/Endpoints/TicketArticles/TicketArticleRepository.php +++ b/src/Endpoints/TicketArticles/TicketArticleRepository.php @@ -4,7 +4,7 @@ namespace ZammadAPIClient\Endpoints\TicketArticles; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; /** * Repository for the `/api/v1/ticket_articles` endpoint. @@ -22,16 +22,6 @@ */ final class TicketArticleRepository extends AbstractRepository { - /** - * Returns 'ticket_articles' — the JSON array key in Zammad's article list response. - * - * Zammad wraps paginated results in `{"ticket_articles": [...], "assets": {...}}`. - */ - protected function getListKey(): string - { - return 'ticket_articles'; - } - /** * Streams all articles belonging to the given ticket, including relation data. * diff --git a/src/Endpoints/TicketPriorities/TicketPriorityRepository.php b/src/Endpoints/TicketPriorities/TicketPriorityRepository.php index bd1e613..714817a 100644 --- a/src/Endpoints/TicketPriorities/TicketPriorityRepository.php +++ b/src/Endpoints/TicketPriorities/TicketPriorityRepository.php @@ -4,7 +4,7 @@ namespace ZammadAPIClient\Endpoints\TicketPriorities; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; /** * Repository for the `/api/v1/ticket_priorities` endpoint. @@ -18,11 +18,4 @@ */ final class TicketPriorityRepository extends AbstractRepository { - /** - * Returns 'ticket_priorities' — the JSON array key in Zammad's paginated priority list response. - */ - protected function getListKey(): string - { - return 'ticket_priorities'; - } } diff --git a/src/Endpoints/TicketStates/TicketStateRepository.php b/src/Endpoints/TicketStates/TicketStateRepository.php index 8227d2a..2e9c130 100644 --- a/src/Endpoints/TicketStates/TicketStateRepository.php +++ b/src/Endpoints/TicketStates/TicketStateRepository.php @@ -4,7 +4,7 @@ namespace ZammadAPIClient\Endpoints\TicketStates; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; /** * Repository for the `/api/v1/ticket_states` endpoint. @@ -19,11 +19,4 @@ */ final class TicketStateRepository extends AbstractRepository { - /** - * Returns 'ticket_states' — the JSON array key in Zammad's paginated state list response. - */ - protected function getListKey(): string - { - return 'ticket_states'; - } } diff --git a/src/Endpoints/Tickets/TicketDTO.php b/src/Endpoints/Tickets/TicketDTO.php index c3ec0ff..d5156d5 100644 --- a/src/Endpoints/Tickets/TicketDTO.php +++ b/src/Endpoints/Tickets/TicketDTO.php @@ -6,7 +6,7 @@ use DateTimeImmutable; use ZammadAPIClient\Core\Contracts\DTOInterface; -use ZammadAPIClient\Core\DtoHydrator; +use ZammadAPIClient\Core\Repository\DtoHydrator; use ZammadAPIClient\Core\Traits\HasTimestamps; use ZammadAPIClient\Core\Traits\SerializesToArray; diff --git a/src/Endpoints/Tickets/TicketRepository.php b/src/Endpoints/Tickets/TicketRepository.php index bd64be9..a68163a 100644 --- a/src/Endpoints/Tickets/TicketRepository.php +++ b/src/Endpoints/Tickets/TicketRepository.php @@ -4,7 +4,7 @@ namespace ZammadAPIClient\Endpoints\Tickets; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; use ZammadAPIClient\Core\Contracts\DeletableInterface; use ZammadAPIClient\Endpoints\TicketArticles\TicketArticleDTO; @@ -20,17 +20,6 @@ */ final class TicketRepository extends AbstractRepository implements DeletableInterface { - /** - * Returns 'tickets' — the JSON array key in Zammad's paginated ticket list response. - * - * Zammad wraps results in `{"tickets": [...], "assets": {...}}`; this key - * tells {@see \ZammadAPIClient\Core\AbstractRepository::extractItems()} where to find the items. - */ - protected function getListKey(): string - { - return 'tickets'; - } - /** * Streams all articles belonging to the given ticket. * diff --git a/src/Endpoints/Tickets/TicketUpdateDTO.php b/src/Endpoints/Tickets/TicketUpdateDTO.php index b6527a5..5a85b4b 100644 --- a/src/Endpoints/Tickets/TicketUpdateDTO.php +++ b/src/Endpoints/Tickets/TicketUpdateDTO.php @@ -7,7 +7,7 @@ use ZammadAPIClient\Core\Contracts\PatchableInterface; /** - * Represents a partial ticket update payload for {@see \ZammadAPIClient\Core\AbstractRepository::patch()}. + * Represents a partial ticket update payload for {@see \ZammadAPIClient\Core\Repository\AbstractRepository::patch()}. * * Only the fields that should be changed need to be set; null fields are omitted * from the API request, leaving those fields unchanged on the server. This avoids @@ -42,7 +42,7 @@ public function __construct( /** * Returns only the non-null fields as an array for the API request body. * - * Called automatically by {@see \ZammadAPIClient\Core\AbstractRepository::patch()} + * Called automatically by {@see \ZammadAPIClient\Core\Repository\AbstractRepository::patch()} * when it detects a {@see \ZammadAPIClient\Core\Contracts\PatchableInterface}. * * @return array diff --git a/src/Endpoints/Users/UserRepository.php b/src/Endpoints/Users/UserRepository.php index 8b31fab..a87bae7 100644 --- a/src/Endpoints/Users/UserRepository.php +++ b/src/Endpoints/Users/UserRepository.php @@ -4,7 +4,7 @@ namespace ZammadAPIClient\Endpoints\Users; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; use ZammadAPIClient\Core\Contracts\DeletableInterface; /** @@ -19,14 +19,6 @@ */ final class UserRepository extends AbstractRepository implements DeletableInterface { - /** - * Returns 'users' — the JSON array key in Zammad's paginated user list response. - */ - protected function getListKey(): string - { - return 'users'; - } - /** * Bulk-imports users from a CSV string. * diff --git a/src/Exceptions/NetworkException.php b/src/Exceptions/NetworkException.php index c87970b..6885030 100644 --- a/src/Exceptions/NetworkException.php +++ b/src/Exceptions/NetworkException.php @@ -8,7 +8,7 @@ * Thrown when a transport-level error prevents the request from completing. * * Unlike HTTP error status codes (which are caught and mapped to typed - * exceptions in {@see \ZammadAPIClient\Core\RequestHandler::dispatch()}), + * exceptions in {@see \ZammadAPIClient\Core\Transport\RequestHandler::dispatch()}), * this exception covers failures that occur before or after an HTTP response * is received: * - DNS resolution failure. diff --git a/src/Factory/GuzzleClientFactory.php b/src/Factory/GuzzleClientFactory.php new file mode 100644 index 0000000..5e1da70 --- /dev/null +++ b/src/Factory/GuzzleClientFactory.php @@ -0,0 +1,87 @@ +config ?? new ConnectionConfig(); + + $url = self::normalizeUrl($this->url); + + $httpClient = new GuzzleClient([ + 'headers' => [ + 'User-Agent' => self::USER_AGENT, + 'Authorization' => $this->authHeader, + ], + 'verify' => $config->verifySsl, + 'timeout' => $config->timeout, + 'connect_timeout' => $config->connectTimeout, + 'allow_redirects' => false, + ]); + + return new RequestHandler( + $httpClient, + new HttpFactory(), + $url, + logger: $config->logger ?? new NullLogger(), + maxRetries: $config->maxRetries, + ); + } + + private static function normalizeUrl(string $url): string + { + $url = rtrim($url, '/'); + + if (!str_contains($url, '/api/')) { + $url .= '/api/v1'; + } + + return $url; + } +} diff --git a/src/ZammadClient.php b/src/ZammadClient.php index 06d15d6..e172616 100644 --- a/src/ZammadClient.php +++ b/src/ZammadClient.php @@ -4,295 +4,124 @@ namespace ZammadAPIClient; -use GuzzleHttp\Client as GuzzleClient; -use GuzzleHttp\Psr7\HttpFactory; -use InvalidArgumentException; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Log\NullLogger; -use ZammadAPIClient\Core\AbstractRepository; -use ZammadAPIClient\Core\ConnectionConfig; +use ZammadAPIClient\Core\Contracts\ClientFactoryInterface; use ZammadAPIClient\Core\Contracts\ClientInterface as ZammadClientInterface; -use ZammadAPIClient\Core\Contracts\DTOInterface; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; -use ZammadAPIClient\Core\RepositoryRegistry; -use ZammadAPIClient\Core\RequestHandler; - +use ZammadAPIClient\Core\Repository\AbstractRepository; +use ZammadAPIClient\Core\Repository\RepositoryRegistry; +use ZammadAPIClient\Endpoints\Groups\GroupRepository; +use ZammadAPIClient\Endpoints\Links\LinkRepository; +use ZammadAPIClient\Endpoints\Organizations\OrganizationRepository; +use ZammadAPIClient\Endpoints\Tags\TagRepository; +use ZammadAPIClient\Endpoints\TextModules\TextModuleRepository; +use ZammadAPIClient\Endpoints\TicketArticles\TicketArticleRepository; +use ZammadAPIClient\Endpoints\TicketPriorities\TicketPriorityRepository; +use ZammadAPIClient\Endpoints\Tickets\TicketRepository; +use ZammadAPIClient\Endpoints\TicketStates\TicketStateRepository; +use ZammadAPIClient\Endpoints\Users\UserRepository; + +/** + * Entry point for the Zammad API. + * + * $client = new ZammadClient( + * GuzzleClientFactory::withToken('https://zammad.example', 'your-token'), + * ); + * + * $client->ticket()->find(1); + * $client->user()->all(); + * + * @internal Prefer {@see ZammadClientInterface} for type hints. + */ final class ZammadClient implements ZammadClientInterface { - public const USER_AGENT = 'Zammad API PHP'; - /** @var array */ private array $repos = []; + private RequestHandlerInterface $handler; + public function __construct( - private RequestHandlerInterface $handler, + RequestHandlerInterface|ClientFactoryInterface $source, ) { + $this->handler = $source instanceof ClientFactoryInterface + ? $source->createHandler() + : $source; } - /** - * Creates a client with token-based authentication (preferred). - * - * $client = ZammadClient::withToken('https://zammad.example', 'your-token'); - */ - public static function withToken( - string $url, - string $token, - ?ConnectionConfig $config = null, - ): self { - $config ??= new ConnectionConfig(); - - return self::createClient($url, $config, "Token token={$token}"); - } - - /** - * Creates a client with OAuth2 Bearer token authentication. - * - * $client = ZammadClient::withOAuth2('https://zammad.example', 'your-oauth-token'); - */ - public static function withOAuth2( - string $url, - string $token, - ?ConnectionConfig $config = null, - ): self { - $config ??= new ConnectionConfig(); - - return self::createClient($url, $config, "Bearer {$token}"); + public function getHandler(): RequestHandlerInterface + { + return $this->handler; } /** - * Creates a client with basic HTTP authentication. + * Returns a memoized repository for the given repository class. * - * $client = ZammadClient::withBasicAuth('https://zammad.example', 'admin@example.com', 'test'); + * @template T of AbstractRepository + * @param class-string $repositoryClass + * @return T */ - public static function withBasicAuth( - string $url, - string $user, - string $pass, - ?ConnectionConfig $config = null, - ): self { - $config ??= new ConnectionConfig(); - - return self::createClient($url, $config, 'Basic ' . base64_encode("{$user}:{$pass}")); - } + public function repo(string $repositoryClass): AbstractRepository + { + if (!isset($this->repos[$repositoryClass])) { + $definition = RepositoryRegistry::definition($repositoryClass); - /** - * Creates a client with a pre-configured PSR-18 HTTP client and PSR-17 factory. - * - * Use this when you need a non-Guzzle transport (e.g. Symfony HttpClient) - * or custom middleware. The caller is responsible for setting auth headers - * (Authorization, User-Agent) on the PSR-18 client. - * - * $client = ZammadClient::withClient( - * $mySymfonyHttpClient, - * $myPsr17Factory, - * 'https://zammad.example', - * ); - */ - public static function withClient( - ClientInterface $httpClient, - RequestFactoryInterface $requestFactory, - string $url, - ?ConnectionConfig $config = null, - ): self { - if (!$requestFactory instanceof StreamFactoryInterface) { - throw new InvalidArgumentException( - 'The factory must implement both RequestFactoryInterface and StreamFactoryInterface.', + $this->repos[$repositoryClass] = new $repositoryClass( + $this->handler, + $definition['path'], + $definition['dto'], ); } - $config ??= new ConnectionConfig(); - - $url = self::normalizeUrl($url); - - $handler = new RequestHandler( - $httpClient, - $requestFactory, - $url, - logger: $config->logger ?? new NullLogger(), - maxRetries: $config->maxRetries, - ); - - return new self($handler); - } - - /** - * Returns the underlying PSR-18 request handler for raw API access. - * - * Use this escape hatch when you need to call an endpoint that has no - * dedicated repository (e.g. ticket deletion via `$client->getHandler()->delete('tickets/1')`). - */ - public function getHandler(): RequestHandlerInterface - { - return $this->handler; + /** @var T */ + return $this->repos[$repositoryClass]; } - private static function normalizeUrl(string $url): string + public function ticket(): TicketRepository { - $url = rtrim($url, '/'); - - if (!str_contains($url, '/api/')) { - $url .= '/api/v1'; - } - - return $url; + return $this->repo(TicketRepository::class); } - private static function createClient(string $url, ConnectionConfig $config, string $authHeader): self + public function user(): UserRepository { - $url = self::normalizeUrl($url); - - $headers = [ - 'User-Agent' => self::USER_AGENT, - 'Authorization' => $authHeader, - ]; - - $httpClient = new GuzzleClient([ - 'headers' => $headers, - 'verify' => $config->verifySsl, - 'timeout' => $config->timeout, - 'connect_timeout' => $config->connectTimeout, - 'allow_redirects' => false, - ]); - - $handler = new RequestHandler( - $httpClient, - new HttpFactory(), - $url, - logger: $config->logger ?? new NullLogger(), - maxRetries: $config->maxRetries, - ); - - return new self($handler); + return $this->repo(UserRepository::class); } - /** - * Activates API-level impersonation for all subsequent requests. - * - * Forwards the identifier as `From` header. May be a user ID, login, - * or email. The header stays active until unsetOnBehalfOfUser() is called. - */ - public function setOnBehalfOfUser(int|string|null $userId): void + public function organization(): OrganizationRepository { - $this->handler->setOnBehalfOfUser($userId); + return $this->repo(OrganizationRepository::class); } - public function unsetOnBehalfOfUser(): void + public function group(): GroupRepository { - $this->handler->setOnBehalfOfUser(null); + return $this->repo(GroupRepository::class); } - /** - * Executes a closure with a temporary impersonation header. - * - * Ruby-style: client.perform_on_behalf_of(user_id) { ... } - * The header is set before the callback and restored afterwards. - */ - public function performOnBehalfOf(int|string $userId, callable $callback): mixed + public function ticketArticle(): TicketArticleRepository { - $previous = $this->handler->getOnBehalfOfUser(); - - $this->handler->setOnBehalfOfUser($userId); - - try { - return $callback($this); - } finally { - $this->handler->setOnBehalfOfUser($previous); - } + return $this->repo(TicketArticleRepository::class); } - /** - * Ruby-style resource accessor: $client->ticket()->find(1) - * - * Maps the method name to a repository using RepositoryRegistry - * via DTO class name lookup. Example: - * ticket() → TicketRepository - * user() → UserRepository - * group() → GroupRepository - * ticket_article() → TicketArticleRepository - * - * @deprecated Use {@see self::repo()} with an explicit repository class - * (e.g. `$client->repo(TicketRepository::class)`) for type-safe, - * IDE-friendly resource access. The magic method will be removed - * in v4.0. - * - * @param array $args - */ - public function __call(string $name, array $args): AbstractRepository + public function ticketState(): TicketStateRepository { - trigger_error( - sprintf( - 'Magic resource accessor $client->%s() is deprecated. Use $client->repo(Repository::class) instead.', - $name, - ), - E_USER_DEPRECATED, - ); - - $class = self::resolveAlias($name); - - if ($class !== null) { - return $this->repo($class); - } - - throw new InvalidArgumentException("Unknown resource: {$name}"); + return $this->repo(TicketStateRepository::class); } - /** @return array> */ - private static function aliasMap(): array + public function ticketPriority(): TicketPriorityRepository { - return [ - 'ticket' => \ZammadAPIClient\Endpoints\Tickets\TicketRepository::class, - 'user' => \ZammadAPIClient\Endpoints\Users\UserRepository::class, - 'organization' => \ZammadAPIClient\Endpoints\Organizations\OrganizationRepository::class, - 'group' => \ZammadAPIClient\Endpoints\Groups\GroupRepository::class, - 'ticket_article' => \ZammadAPIClient\Endpoints\TicketArticles\TicketArticleRepository::class, - 'ticket_state' => \ZammadAPIClient\Endpoints\TicketStates\TicketStateRepository::class, - 'ticket_priority' => \ZammadAPIClient\Endpoints\TicketPriorities\TicketPriorityRepository::class, - 'tag' => \ZammadAPIClient\Endpoints\Tags\TagRepository::class, - 'text_module' => \ZammadAPIClient\Endpoints\TextModules\TextModuleRepository::class, - 'link' => \ZammadAPIClient\Endpoints\Links\LinkRepository::class, - ]; + return $this->repo(TicketPriorityRepository::class); } - /** - * @return ?class-string - */ - private static function resolveAlias(string $name): ?string + public function tag(): TagRepository { - return self::aliasMap()[$name] ?? null; + return $this->repo(TagRepository::class); } - /** - * Returns a memoized repository for the given repository class. - * - * @template T of AbstractRepository - * @param class-string $repositoryClass - * @return T - */ - public function repo(string $repositoryClass): AbstractRepository + public function textModule(): TextModuleRepository { - $definition = RepositoryRegistry::definition($repositoryClass); - - /** @var T */ - return $this->repository($repositoryClass, $definition['path'], $definition['dto']); + return $this->repo(TextModuleRepository::class); } - /** - * @template T of AbstractRepository - * @param class-string $repoClass - * @param class-string $dtoClass - * @return T - */ - private function repository(string $repoClass, string $path, string $dtoClass): object + public function link(): LinkRepository { - if (!isset($this->repos[$repoClass])) { - $this->repos[$repoClass] = new $repoClass($this->handler, $path, $dtoClass); - } - - /** @var T $repo */ - $repo = $this->repos[$repoClass]; - - return $repo; + return $this->repo(LinkRepository::class); } } diff --git a/test/Integration/Traits/CreatesZammadClient.php b/test/Integration/Traits/CreatesZammadClient.php index ccbbca9..1b748de 100644 --- a/test/Integration/Traits/CreatesZammadClient.php +++ b/test/Integration/Traits/CreatesZammadClient.php @@ -4,6 +4,7 @@ namespace ZammadAPIClient\Tests\Integration\Traits; +use ZammadAPIClient\Factory\GuzzleClientFactory; use ZammadAPIClient\ZammadClient; /** @@ -27,11 +28,15 @@ protected static function createZammadClient(): ZammadClient } if ($token !== null && $token !== '') { - return ZammadClient::withToken($url, $token); + return new ZammadClient( + GuzzleClientFactory::withToken($url, $token), + ); } if ($user !== null && $pass !== null) { - return ZammadClient::withBasicAuth($url, $user, $pass); + return new ZammadClient( + GuzzleClientFactory::withBasicAuth($url, $user, $pass), + ); } self::markTestSkipped('Missing ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN or USERNAME/PASSWORD.'); diff --git a/test/Unit/Core/AbstractRepositoryTest.php b/test/Unit/Core/AbstractRepositoryTest.php index f250f53..3a3796f 100644 --- a/test/Unit/Core/AbstractRepositoryTest.php +++ b/test/Unit/Core/AbstractRepositoryTest.php @@ -7,11 +7,11 @@ use Mockery; use Mockery\Adapter\Phpunit\MockeryTestCase; use PHPUnit\Framework\Attributes\Group; -use ZammadAPIClient\Core\AbstractRepository; +use ZammadAPIClient\Core\Repository\AbstractRepository; use ZammadAPIClient\Core\Contracts\DTOInterface; use ZammadAPIClient\Core\Contracts\PatchableInterface; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; -use ZammadAPIClient\Core\Resource; +use ZammadAPIClient\Core\Repository\Resource; use ZammadAPIClient\Endpoints\Tickets\TicketDTO; #[Group('unit')] diff --git a/test/Unit/Core/HttpPageFetcherTest.php b/test/Unit/Core/HttpPageFetcherTest.php index fdb51af..871261c 100644 --- a/test/Unit/Core/HttpPageFetcherTest.php +++ b/test/Unit/Core/HttpPageFetcherTest.php @@ -8,7 +8,7 @@ use Mockery\Adapter\Phpunit\MockeryTestCase; use PHPUnit\Framework\Attributes\Group; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; -use ZammadAPIClient\Core\HttpPageFetcher; +use ZammadAPIClient\Core\Transport\HttpPageFetcher; use ZammadAPIClient\Endpoints\Tickets\TicketDTO; #[Group('unit')] @@ -20,7 +20,7 @@ public function testFetchIndexEndpoint(): void $handler->expects('get') ->with('tickets', ['page' => '1', 'per_page' => '10']) ->once() - ->andReturn(['tickets' => [['id' => 1, 'title' => 'Hello']]]); + ->andReturn(['tickets' => [['id' => 1, 'title' => 'Hello']], 'total_count' => 42]); $fetcher = new HttpPageFetcher($handler, TicketDTO::class, 'tickets', 'tickets'); @@ -29,7 +29,7 @@ public function testFetchIndexEndpoint(): void self::assertCount(1, $result['items']); self::assertInstanceOf(TicketDTO::class, $result['items'][0]); self::assertSame(1, $result['items'][0]->id); - self::assertNull($result['total_count']); + self::assertSame(42, $result['total_count']); } public function testFetchSearchEndpoint(): void diff --git a/test/Unit/Core/ImpersonationHandlerTest.php b/test/Unit/Core/ImpersonationHandlerTest.php new file mode 100644 index 0000000..f1a83ce --- /dev/null +++ b/test/Unit/Core/ImpersonationHandlerTest.php @@ -0,0 +1,175 @@ +inner = Mockery::mock(RequestHandlerInterface::class); + $this->handler = new ImpersonationHandler($this->inner, 1); + } + + public function testRequestInjectsFromHeader(): void + { + $this->inner->expects('request') + ->once() + ->with( + 'GET', + '/api/v1/tickets', + Mockery::on(function (array $options) { + return ($options['headers']['From'] ?? null) === '1'; + }), + ) + ->andReturn([]); + + $this->handler->request('GET', '/api/v1/tickets'); + } + + public function testGetDelegatesWithFromHeader(): void + { + $this->inner->expects('request') + ->once() + ->with( + 'GET', + '/api/v1/tickets', + Mockery::on(function (array $options) { + return ($options['headers']['From'] ?? null) === '1'; + }), + ) + ->andReturn([]); + + $this->handler->get('/api/v1/tickets'); + } + + public function testPostDelegatesWithFromHeader(): void + { + $this->inner->expects('request') + ->once() + ->with( + 'POST', + '/api/v1/tickets', + Mockery::on(function (array $options) { + return ($options['headers']['From'] ?? null) === '1' + && ($options['headers']['Content-Type'] ?? null) === 'application/json'; + }), + ) + ->andReturn([]); + + $this->handler->post('/api/v1/tickets', ['title' => 'Test']); + } + + public function testPutDelegatesWithFromHeader(): void + { + $this->inner->expects('request') + ->once() + ->with( + 'PUT', + '/api/v1/tickets/1', + Mockery::on(function (array $options) { + return ($options['headers']['From'] ?? null) === '1' + && ($options['headers']['Content-Type'] ?? null) === 'application/json'; + }), + ) + ->andReturn([]); + + $this->handler->put('/api/v1/tickets/1', ['title' => 'Updated']); + } + + public function testDeleteDelegatesWithFromHeader(): void + { + $this->inner->expects('request') + ->once() + ->with( + 'DELETE', + '/api/v1/tickets/1', + Mockery::on(function (array $options) { + return ($options['headers']['From'] ?? null) === '1'; + }), + ) + ->andReturn([]); + + $this->handler->delete('/api/v1/tickets/1'); + } + + public function testGetRawDelegatesWithFromHeader(): void + { + $this->inner->expects('getRaw') + ->once() + ->with( + '/api/v1/ticket_attachment/1/2/3', + [], + Mockery::on(function (array $headers) { + return ($headers['From'] ?? null) === '1'; + }), + ) + ->andReturn('binary-content'); + + $result = $this->handler->getRaw('/api/v1/ticket_attachment/1/2/3'); + + self::assertSame('binary-content', $result); + } + + public function testGetLastResponseDelegates(): void + { + $response = Mockery::mock(ResponseInterface::class); + + $this->inner->expects('getLastResponse') + ->once() + ->andReturn($response); + + self::assertSame($response, $this->handler->getLastResponse()); + } + + public function testInnerHandlerIsNeverMutated(): void + { + $this->inner->allows('request')->andReturn([]); + $this->inner->allows('getRaw')->andReturn(''); + $this->inner->allows('getLastResponse')->andReturn(null); + + $this->handler->get('/api/v1/tickets'); + $this->handler->post('/api/v1/tickets', []); + $this->handler->put('/api/v1/tickets/1', []); + $this->handler->delete('/api/v1/tickets/1'); + $this->handler->getRaw('/api/v1/attachment/1/2/3'); + $this->handler->getLastResponse(); + + $this->inner->shouldNotHaveReceived('setOnBehalfOfUser'); + $this->inner->shouldNotHaveReceived('getOnBehalfOfUser'); + + self::assertTrue(true); + } + + public function testRequestPreservesOptionsExceptFromHeader(): void + { + $this->inner->expects('request') + ->once() + ->with( + 'POST', + 'tickets', + Mockery::on(function (array $options) { + return ($options['headers']['From'] ?? null) === '1' + && $options['headers']['Content-Type'] === 'application/json' + && $options['body'] === '{"title":"Test"}'; + }), + ) + ->andReturn([]); + + $this->handler->post('tickets', ['title' => 'Test']); + } +} diff --git a/test/Unit/Core/PaginatedListTest.php b/test/Unit/Core/PaginatedListTest.php index f814cf1..70b05a0 100644 --- a/test/Unit/Core/PaginatedListTest.php +++ b/test/Unit/Core/PaginatedListTest.php @@ -10,8 +10,8 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use ZammadAPIClient\Core\PaginatedList; -use ZammadAPIClient\Core\RequestHandler; +use ZammadAPIClient\Core\Repository\PaginatedList; +use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\Endpoints\Tickets\TicketDTO; #[Group('unit')] diff --git a/test/Unit/Core/RepositoryRegistryTest.php b/test/Unit/Core/RepositoryRegistryTest.php index b39ed4d..c517ce4 100644 --- a/test/Unit/Core/RepositoryRegistryTest.php +++ b/test/Unit/Core/RepositoryRegistryTest.php @@ -7,7 +7,7 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -use ZammadAPIClient\Core\RepositoryRegistry; +use ZammadAPIClient\Core\Repository\RepositoryRegistry; use ZammadAPIClient\Endpoints\Tickets\TicketDTO; use ZammadAPIClient\Endpoints\Tickets\TicketRepository; diff --git a/test/Unit/Core/RequestHandlerTest.php b/test/Unit/Core/RequestHandlerTest.php index a62278d..ad9030a 100644 --- a/test/Unit/Core/RequestHandlerTest.php +++ b/test/Unit/Core/RequestHandlerTest.php @@ -14,7 +14,7 @@ use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use ZammadAPIClient\Core\RequestHandler; +use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\Exceptions\AuthenticationException; use ZammadAPIClient\Exceptions\ForbiddenException; use ZammadAPIClient\Exceptions\NetworkException; @@ -144,28 +144,6 @@ public function testGetRawReturnsUndecodedBody(): void self::assertSame($binary, $this->handler->getRaw('ticket_attachment/1/2/3')); } - public function testOnBehalfOfHeaderIsApplied(): void - { - $this->httpClient->response = new Response(200, [], '{}'); - $this->handler->setOnBehalfOfUser(7); - - $this->handler->get('tickets'); - - self::assertNotNull($this->httpClient->lastRequest); - self::assertSame('7', $this->httpClient->lastRequest->getHeaderLine('From')); - } - - public function testOnBehalfOfWithStringLogin(): void - { - $this->httpClient->response = new Response(200, [], '{}'); - $this->handler->setOnBehalfOfUser('agent@example.com'); - - $this->handler->get('tickets'); - - self::assertNotNull($this->httpClient->lastRequest); - self::assertSame('agent@example.com', $this->httpClient->lastRequest->getHeaderLine('From')); - } - public function testNonJsonBodyOn200ThrowsNetworkException(): void { $this->httpClient->response = new Response(200, [], 'proxy error'); @@ -189,20 +167,6 @@ public function testGetLastResponseReturnsLastResponseAfterRequest(): void self::assertNotNull($this->handler->getLastResponse()); } - public function testGetOnBehalfOfUserReturnsSetValue(): void - { - self::assertNull($this->handler->getOnBehalfOfUser()); - - $this->handler->setOnBehalfOfUser(7); - self::assertSame(7, $this->handler->getOnBehalfOfUser()); - - $this->handler->setOnBehalfOfUser('user@example.com'); - self::assertSame('user@example.com', $this->handler->getOnBehalfOfUser()); - - $this->handler->setOnBehalfOfUser(null); - self::assertNull($this->handler->getOnBehalfOfUser()); - } - public function testGetRawWithQueryParamsAppendsToUri(): void { $this->httpClient->response = new Response(200, [], 'binary'); diff --git a/test/Unit/Core/ResourceTest.php b/test/Unit/Core/ResourceTest.php index 815c013..4e3bf36 100644 --- a/test/Unit/Core/ResourceTest.php +++ b/test/Unit/Core/ResourceTest.php @@ -10,8 +10,8 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use ZammadAPIClient\Core\RequestHandler; -use ZammadAPIClient\Core\Resource; +use ZammadAPIClient\Core\Transport\RequestHandler; +use ZammadAPIClient\Core\Repository\Resource; use ZammadAPIClient\Endpoints\Tickets\TicketDTO; #[Group('unit')] diff --git a/test/Unit/Core/ResponseParserTest.php b/test/Unit/Core/ResponseParserTest.php index e22c4ea..744522b 100644 --- a/test/Unit/Core/ResponseParserTest.php +++ b/test/Unit/Core/ResponseParserTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -use ZammadAPIClient\Core\ResponseParser; +use ZammadAPIClient\Core\Repository\ResponseParser; #[Group('unit')] final class ResponseParserTest extends TestCase diff --git a/test/Unit/Core/RetryAfterMiddlewareTest.php b/test/Unit/Core/RetryAfterMiddlewareTest.php index 3827486..fabd9a2 100644 --- a/test/Unit/Core/RetryAfterMiddlewareTest.php +++ b/test/Unit/Core/RetryAfterMiddlewareTest.php @@ -11,7 +11,7 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use ZammadAPIClient\Core\RetryAfterMiddleware; +use ZammadAPIClient\Core\Transport\RetryAfterMiddleware; #[Group('unit')] final class RetryAfterMiddlewareTest extends TestCase diff --git a/test/Unit/Core/Traits/CreatesRequestHandler.php b/test/Unit/Core/Traits/CreatesRequestHandler.php index 5e32103..77d3ddc 100644 --- a/test/Unit/Core/Traits/CreatesRequestHandler.php +++ b/test/Unit/Core/Traits/CreatesRequestHandler.php @@ -6,7 +6,7 @@ use GuzzleHttp\Psr7\HttpFactory; use Psr\Http\Client\ClientInterface; -use ZammadAPIClient\Core\RequestHandler; +use ZammadAPIClient\Core\Transport\RequestHandler; /** * @mixin \PHPUnit\Framework\TestCase diff --git a/test/Unit/GuzzleClientFactoryTest.php b/test/Unit/GuzzleClientFactoryTest.php new file mode 100644 index 0000000..fceaa26 --- /dev/null +++ b/test/Unit/GuzzleClientFactoryTest.php @@ -0,0 +1,45 @@ +repo('NotARepository'); } - public function testCallResolvesTicketRepository(): void + public function testGetHandlerReturnsInjectedHandler(): void { $handler = Mockery::mock(RequestHandlerInterface::class); $client = new ZammadClient($handler); - $repo = @$client->ticket(); - - self::assertInstanceOf(TicketRepository::class, $repo); + self::assertSame($handler, $client->getHandler()); } - public function testCallResolvesUserRepository(): void + public function testTicketAccessorDelegatesToRepo(): void { $handler = Mockery::mock(RequestHandlerInterface::class); $client = new ZammadClient($handler); - $repo = @$client->user(); - - self::assertInstanceOf(UserRepository::class, $repo); - } - - public function testCallMemoizesRepository(): void - { - $handler = Mockery::mock(RequestHandlerInterface::class); - $client = new ZammadClient($handler); + $ticketRepo = $client->ticket(); - self::assertSame(@$client->ticket(), @$client->ticket()); + self::assertInstanceOf(TicketRepository::class, $ticketRepo); } - public function testCallThrowsForUnknownResource(): void + public function testUserAccessorDelegatesToRepo(): void { $handler = Mockery::mock(RequestHandlerInterface::class); $client = new ZammadClient($handler); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown resource'); - - @$client->nonexistent(); - } - - public function testCallResolvesUnderscoreResources(): void - { - $handler = Mockery::mock(RequestHandlerInterface::class); - $client = new ZammadClient($handler); - - $repo = @$client->ticket_article(); - - self::assertInstanceOf(\ZammadAPIClient\Endpoints\TicketArticles\TicketArticleRepository::class, $repo); - } - - public function testSetOnBehalfOfUserDelegatesToHandler(): void - { - $handler = Mockery::mock(RequestHandlerInterface::class); - $handler->expects('setOnBehalfOfUser')->with(7)->once(); - - $client = new ZammadClient($handler); - $client->setOnBehalfOfUser(7); - } - - public function testUnsetOnBehalfOfUserDelegatesToHandler(): void - { - $handler = Mockery::mock(RequestHandlerInterface::class); - $handler->expects('setOnBehalfOfUser')->with(null)->once(); - - $client = new ZammadClient($handler); - $client->unsetOnBehalfOfUser(); - } - - public function testPerformOnBehalfOfExecutesCallbackAndResets(): void - { - $handler = Mockery::mock(RequestHandlerInterface::class); - $handler->expects('getOnBehalfOfUser')->andReturn(null)->once(); - $handler->expects('setOnBehalfOfUser')->with(7)->once(); - $handler->expects('setOnBehalfOfUser')->with(null)->once(); - - $client = new ZammadClient($handler); - - $result = $client->performOnBehalfOf(7, fn() => 42); - - self::assertSame(42, $result); - } - - public function testPerformOnBehalfOfResetsOnException(): void - { - $handler = Mockery::mock(RequestHandlerInterface::class); - $handler->expects('getOnBehalfOfUser')->andReturn(null)->once(); - $handler->expects('setOnBehalfOfUser')->with(7)->once(); - $handler->expects('setOnBehalfOfUser')->with(null)->once(); - - $client = new ZammadClient($handler); - - $this->expectException(\RuntimeException::class); - - $client->performOnBehalfOf(7, function () { - throw new \RuntimeException('test'); - }); - } - - public function testWithClientUsesInjectedHttpClient(): void - { - $httpClient = Mockery::mock(ClientInterface::class); - $httpClient->expects('sendRequest') - ->once() - ->andReturnUsing(function (RequestInterface $request) { - self::assertStringContainsString('tickets', (string) $request->getUri()); - - return new \GuzzleHttp\Psr7\Response(200, [], (string) json_encode([ - 'tickets' => [ - ['id' => 1, 'title' => 'T1', 'group_id' => 1], - ], - ])); - }); - - $client = ZammadClient::withClient( - $httpClient, - new HttpFactory(), - 'https://zammad.example/api/v1', - ); - - $repo = $client->repo(TicketRepository::class); - $tickets = iterator_to_array($repo->all()); - - self::assertCount(1, $tickets); - self::assertSame(1, $tickets[0]->id); - } - - public function testWithClientThrowsWhenFactoryNotStreamFactory(): void - { - $factory = Mockery::mock(RequestFactoryInterface::class); - $httpClient = Mockery::mock(ClientInterface::class); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('implement both'); - - ZammadClient::withClient( - $httpClient, - $factory, - 'https://zammad.example/api/v1', - ); + self::assertInstanceOf(UserRepository::class, $client->user()); } } From a09735a3febca62964a4c19373c3ee317042cfa4 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Tue, 21 Jul 2026 08:12:11 +0200 Subject: [PATCH 2/2] feat: split cookbook into standalone recipe files, add integration test - Delete monolithic examples/cookbook.php - 00-plain.php: Guzzle setup, copy-paste-ready - 00-laravel.php: Laravel service container reference - 00-symfony.php: Symfony bundle reference - 00-slim.php: Non-Guzzle setup (Symfony HttpClient + Nyholm) - 01-quick-start.php: client setup + find() - 02-crud.php: create, read, delete, error handling - 03-listing.php: all() streaming, list() pagination, totalCount() - 04-updates.php: patch(), TicketUpdateDTO, Resource wrapper - 05-impersonation.php: ImpersonationHandler decoration - 06-search.php: search(), searchList(), pagination - CookbookIntegrationTest: executes recipes 01-06 via exec() - README.md: recipe overview and run instructions --- Makefile | 15 +- examples/cookbook.php | 147 ------------------- examples/cookbook/00-laravel.php | 32 ++++ examples/cookbook/00-plain.php | 31 ++++ examples/cookbook/00-slim.php | 44 ++++++ examples/cookbook/00-symfony.php | 35 +++++ examples/cookbook/01-quick-start.php | 51 +++++++ examples/cookbook/02-crud.php | 70 +++++++++ examples/cookbook/03-listing.php | 59 ++++++++ examples/cookbook/04-updates.php | 69 +++++++++ examples/cookbook/05-impersonation.php | 69 +++++++++ examples/cookbook/06-search.php | 67 +++++++++ examples/cookbook/README.md | 44 ++++++ test/Integration/CookbookIntegrationTest.php | 63 ++++++++ 14 files changed, 644 insertions(+), 152 deletions(-) delete mode 100644 examples/cookbook.php create mode 100644 examples/cookbook/00-laravel.php create mode 100644 examples/cookbook/00-plain.php create mode 100644 examples/cookbook/00-slim.php create mode 100644 examples/cookbook/00-symfony.php create mode 100644 examples/cookbook/01-quick-start.php create mode 100644 examples/cookbook/02-crud.php create mode 100644 examples/cookbook/03-listing.php create mode 100644 examples/cookbook/04-updates.php create mode 100644 examples/cookbook/05-impersonation.php create mode 100644 examples/cookbook/06-search.php create mode 100644 examples/cookbook/README.md create mode 100644 test/Integration/CookbookIntegrationTest.php diff --git a/Makefile b/Makefile index 208c7ca..ea9d217 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help test test-integration coverage clean +.PHONY: help test test-integration coverage cookbook clean help: ## Show available make targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' @@ -36,13 +36,18 @@ test-integration: ## Run integration tests (needs Zammad instance) echo " Skip (test/Integration/ or vendor/ not present)"; \ fi -clean: ## Remove build artifacts - rm -rf build/ - rm -f .phpunit.cache - coverage: ## Run unit tests with coverage report (build/coverage/html/) @if [ -f vendor/bin/phpunit ] && [ -d test/Unit ]; then \ php -d xdebug.mode=coverage vendor/bin/phpunit --testsuite=unit; \ else \ echo " Skip (test/Unit/ or vendor/ not present)"; \ fi + +cookbook: ## Run all cookbook recipes (needs Zammad instance) + @for f in examples/cookbook/0[1-6]*.php; do \ + echo "=== $$f ===" && php "$$f"; \ + done + +clean: ## Remove build artifacts + rm -rf build/ + rm -f .phpunit.cache diff --git a/examples/cookbook.php b/examples/cookbook.php deleted file mode 100644 index 769a321..0000000 --- a/examples/cookbook.php +++ /dev/null @@ -1,147 +0,0 @@ -ticket()` returns TicketRepository -$repo = $client->ticket(); - -// ── Caching reference data ────────────────────────────────────── -// States and priorities rarely change. The simple static cache below -// avoids repeated HTTP calls within one script run. For production: -// PSR-6/16 with Memcached: $cachePool->get('zammad.priorities') -// Laravel: Cache::remember('zammad.priorities', 3600, fn() => ...) -// Plain PHP (apcu): apcu_fetch('zammad.priorities') ?? refresh... -function memoize(string $key, callable $fn): mixed { - static $cache = []; - return $cache[$key] ??= $fn(); -} - -$priorities = memoize('priorities', fn() => - [...$client->ticketPriority()->all()] -); -$states = memoize('states', fn() => - [...$client->ticketState()->all()] -); - -$normalPriorityId = null; -$openStateId = null; - -foreach ($priorities as $p) { - if (mb_stripos($p->name, 'normal') !== false) { - $normalPriorityId = $p->id; - break; - } -} - -foreach ($states as $s) { - if (mb_stripos($s->name, 'open') !== false) { - $openStateId = $s->id; - break; - } -} - -// ── Recipe 1: Create a ticket to work with ────────────────────── -$hostTicket = $repo->create(new TicketDTO( - title: 'Cookbook Host ' . date('Y-m-d H:i:s'), - customer_id: 1, - group_id: 1, - priority_id: $normalPriorityId ?? 2, - state_id: $openStateId ?? 1, - article: [ - 'subject' => 'Host ticket', - 'body' => 'This is the ticket used by the cookbook recipes.', - 'type' => TicketArticleType::Note->value, - ], -)); -$ticketId = $hostTicket->id; -echo "✓ Created host ticket #{$ticketId}: {$hostTicket->title}\n"; - -// ── Recipe 2: Fetch a ticket ──────────────────────────────────── -$ticket = $repo->find($ticketId); -echo "✓ Fetched ticket #{$ticketId}: {$ticket->title}\n"; - -// ── Recipe 3: Stateful resource with changes tracking ─────────── -$resource = $repo->resource($ticketId); -$resource->title = 'Updated via Resource ' . date('H:i:s'); -$resource->save(); -echo "✓ Ticket #{$resource->id} updated: {$resource->title}\n"; - -// ── Recipe 4: Create via DTO ──────────────────────────────────── -$created = $repo->create(new TicketDTO( - title: 'Cookbook Test ' . date('H:i:s'), - customer_id: 1, - group_id: 1, - priority_id: $normalPriorityId ?? 2, - state_id: $openStateId ?? 1, - article: [ - 'subject' => 'Cookbook Test', - 'body' => 'Created via cookbook recipe 4', - 'type' => TicketArticleType::Note->value, - ], -)); -echo "✓ Ticket #{$created->id} created: {$created->title}\n"; - -// ── Recipe 5: Paginated list with navigation ──────────────────── -$list = $repo->list(['expand' => 'true']); -$list->page(1); -echo "✓ Page 1: " . count($list) . " tickets\n"; - -// ── Recipe 6: Error handling ──────────────────────────────────── -try { - $repo->find(999999); -} catch (NotFoundException $e) { - echo "✓ NotFoundException caught: {$e->getMessage()}\n"; -} - -// ── Recipe 7: Delete a ticket via repository ──────────────────── -$repo->delete($created->id); -echo "✓ Ticket #{$created->id} deleted\n"; - -// ── Recipe 8: On-Behalf-Of impersonation ──────────────────────── -$imp = new \ZammadAPIClient\Core\Transport\ImpersonationHandler($client->getHandler(), 1); -$scoped = new ZammadClient($imp); -echo " (acting as user #1) Ticket #{$ticketId} title: " - . $scoped->ticket()->find($ticketId)->title . "\n"; -echo "✓ Impersonation complete\n"; - -// ── Recipe 9: Search ──────────────────────────────────────────── -$count = 0; -foreach ($repo->search('cookbook') as $t) { - $count++; -} -echo "✓ Search found {$count} tickets\n"; - -// Clean up the host ticket -$repo->delete($ticketId); -echo "✓ Host ticket #{$ticketId} cleaned up\n"; - -echo "\nAll recipes executed.\n"; diff --git a/examples/cookbook/00-laravel.php b/examples/cookbook/00-laravel.php new file mode 100644 index 0000000..551f04a --- /dev/null +++ b/examples/cookbook/00-laravel.php @@ -0,0 +1,32 @@ +zammad->ticket()->find($id); + * } + * } + * + * Or resolve manually: + * $client = app(ZammadClient::class); + */ + +declare(strict_types=1); + +// Already registered by the service provider — this file is for reference. +fwrite(STDOUT, "See docblock above for Laravel setup instructions.\n"); diff --git a/examples/cookbook/00-plain.php b/examples/cookbook/00-plain.php new file mode 100644 index 0000000..eb7a6cc --- /dev/null +++ b/examples/cookbook/00-plain.php @@ -0,0 +1,31 @@ +create([ + 'headers' => [ + 'User-Agent' => 'Zammad API PHP', + 'Authorization' => "Token token={$token}", + ], + ]), +); + +$psr17 = new Psr17Factory(); + +$handler = new RequestHandler($symfonyClient, $psr17, $url); +$client = new ZammadClient($handler); + +echo "Connected to {$url} (Symfony HttpClient)\n"; +echo "Ticket #1: " . $client->ticket()->find(1)->title . "\n"; diff --git a/examples/cookbook/00-symfony.php b/examples/cookbook/00-symfony.php new file mode 100644 index 0000000..1e4b31c --- /dev/null +++ b/examples/cookbook/00-symfony.php @@ -0,0 +1,35 @@ + ['all' => true] + * config/packages/zammad.yaml: + * zammad: + * url: '%env(ZAMMAD_URL)%' + * token: '%env(ZAMMAD_TOKEN)%' + * + * Usage in service: + * + * use ZammadAPIClient\ZammadClient; + * + * class TicketService + * { + * public function __construct(private ZammadClient $zammad) {} + * + * public function findTicket(int $id) + * { + * return $this->zammad->ticket()->find($id); + * } + * } + * + * Or resolve manually: + * $client = $container->get(ZammadClient::class); + */ + +declare(strict_types=1); + +// Already registered by the bundle — this file is for reference. +fwrite(STDOUT, "See docblock above for Symfony setup instructions.\n"); diff --git a/examples/cookbook/01-quick-start.php b/examples/cookbook/01-quick-start.php new file mode 100644 index 0000000..8adc6d8 --- /dev/null +++ b/examples/cookbook/01-quick-start.php @@ -0,0 +1,51 @@ +ticket(); +try { + $ticket = $repo->find(1); + echo "find(1): #{$ticket->id} {$ticket->title}\n"; +} catch (NotFoundException) { + echo "find(1): no ticket #1 — run 02-crud.php first\n"; +} + +try { + $user = $client->user()->find(1); + echo "user(1): {$user->firstname} {$user->lastname}\n"; +} catch (NotFoundException) { + echo "user(1): no user #1\n"; +} + +echo "\nDone.\n"; diff --git a/examples/cookbook/02-crud.php b/examples/cookbook/02-crud.php new file mode 100644 index 0000000..aa964ea --- /dev/null +++ b/examples/cookbook/02-crud.php @@ -0,0 +1,70 @@ +ticket(); + +// ── Create ────────────────────────────────────────────── +echo "── Create ──\n"; +$created = $repo->create(new TicketDTO( + title: 'Cookbook CRUD ' . date('Y-m-d H:i:s'), + customer_id: 1, + group_id: 1, + article: [ + 'subject' => 'CRUD test', + 'body' => 'Created via cookbook 02-crud.', + 'type' => TicketArticleType::Note->value, + ], +)); +echo "Created ticket #{$created->id}\n"; +echo " title: {$created->title}\n"; + +// ── Read ──────────────────────────────────────────────── +echo "\n── Read ──\n"; +$ticket = $repo->find($created->id); +echo "find({$created->id}): {$ticket->title}\n"; + +// ── Error ────────────────────────────────────────────── +echo "\n── Error handling ──\n"; +try { + $repo->find(999999); +} catch (NotFoundException $e) { + echo "find(999999): NotFoundException\n"; +} + +// ── Delete ────────────────────────────────────────────── +echo "\n── Delete ──\n"; +$repo->delete($created->id); +echo "Deleted ticket #{$created->id}\n"; + +echo "\nDone.\n"; diff --git a/examples/cookbook/03-listing.php b/examples/cookbook/03-listing.php new file mode 100644 index 0000000..e689009 --- /dev/null +++ b/examples/cookbook/03-listing.php @@ -0,0 +1,59 @@ +ticket(); + +// ── totalCount() — single API call ────────────────────── +echo "── Total count ──\n"; +echo "totalCount(): {$repo->totalCount()} tickets\n"; + +// ── all() — streaming generator ───────────────────────── +echo "\n── all() streaming ──\n"; +$count = 0; +foreach ($repo->all() as $ticket) { + $count++; + if ($count <= 3) { + echo " #{$ticket->id} {$ticket->title}\n"; + } +} +echo "Streamed {$count} tickets\n"; + +// ── list() — page navigation ──────────────────────────── +echo "\n── list() pagination ──\n"; +$list = $repo->list(); +$list->page(1); +echo "Page 1: " . count($list) . " tickets\n"; + +if ($list[0] !== null) { + echo " First: {$list[0]->title}\n"; +} + +echo "\nDone.\n"; diff --git a/examples/cookbook/04-updates.php b/examples/cookbook/04-updates.php new file mode 100644 index 0000000..3f96e5f --- /dev/null +++ b/examples/cookbook/04-updates.php @@ -0,0 +1,69 @@ +ticket(); + +// ── Create a ticket to work with ──────────────────────── +$ticket = $repo->create(new TicketDTO( + title: 'Cookbook Update ' . date('Y-m-d H:i:s'), + customer_id: 1, + group_id: 1, + article: [ + 'subject' => 'Before update', + 'body' => 'This ticket will be updated.', + 'type' => TicketArticleType::Note->value, + ], +)); +echo "Created ticket #{$ticket->id}\n"; + +// ── Patch via array ───────────────────────────────────── +echo "\n── patch() via array ──\n"; +$updated = $repo->patch($ticket->id, ['title' => 'Patched array ' . date('H:i:s')]); +echo "title: {$updated->title}\n"; + +// ── Patch via TicketUpdateDTO ─────────────────────────── +echo "\n── patch() via TicketUpdateDTO ──\n"; +$dto = new TicketUpdateDTO(title: 'Patched DTO ' . date('H:i:s')); +$updated = $repo->patch($ticket->id, $dto); +echo "title: {$updated->title}\n"; + +// ── Resource wrapper (stateful) ───────────────────────── +echo "\n── Resource wrapper ──\n"; +$resource = $repo->resource($ticket->id); +$resource->title = 'Resource ' . date('H:i:s'); +$resource->save(); +echo "title: {$resource->title}\n"; + +$repo->delete($ticket->id); +echo "\nCleaned up.\nDone.\n"; diff --git a/examples/cookbook/05-impersonation.php b/examples/cookbook/05-impersonation.php new file mode 100644 index 0000000..64a0829 --- /dev/null +++ b/examples/cookbook/05-impersonation.php @@ -0,0 +1,69 @@ +ticket(); + +// ── Create a ticket — then find it impersonated ───────── +echo "── Create ticket ──\n"; +$created = $repo->create(new TicketDTO( + title: 'Cookbook Impersonation ' . date('Y-m-d H:i:s'), + customer_id: 1, // owner — visible when impersonating user #1 + group_id: 1, + article: [ + 'subject' => 'Impersonation test', + 'body' => 'Created for impersonation demo.', + 'type' => TicketArticleType::Note->value, + ], +)); +echo "Created ticket #{$created->id}\n"; + +// ── Impersonate user #1 (stateless, no leak) ──────────── +echo "\n── Impersonate user #1 ──\n"; +$imp = new ImpersonationHandler($client->getHandler(), 1); +$scoped = new ZammadClient($imp); + +try { + $ticket = $scoped->ticket()->find($created->id); + echo "As user #1 — ticket #{$created->id}: {$ticket->title}\n"; +} catch (ForbiddenException) { + echo "User #1 cannot access ticket #{$created->id} (expected)\n"; +} + +// ── Original client unchanged ─────────────────────────── +echo "\n── Original client ──\n"; +echo "Outer handler untouched: yes\n"; + +$repo->delete($created->id); +echo "\nCleaned up.\nDone.\n"; diff --git a/examples/cookbook/06-search.php b/examples/cookbook/06-search.php new file mode 100644 index 0000000..ac436b1 --- /dev/null +++ b/examples/cookbook/06-search.php @@ -0,0 +1,67 @@ +ticket(); + +// ── Create a searchable ticket ────────────────────────── +$ticket = $repo->create(new TicketDTO( + title: 'Cookbook Searchable ' . date('Y-m-d H:i:s'), + customer_id: 1, + group_id: 1, + article: [ + 'subject' => 'Searchable ticket', + 'body' => 'This ticket is used for search demos.', + 'type' => TicketArticleType::Note->value, + ], +)); +echo "Created ticket #{$ticket->id}\n\n"; + +// ── search() streaming ────────────────────────────────── +echo "── search('cookbook') streaming ──\n"; +$count = 0; +foreach ($repo->search('cookbook') as $result) { + $count++; +} +echo "Results: {$count}\n"; + +// ── searchList() with total count ─────────────────────── +echo "\n── searchList pagination ──\n"; +$all = $repo->searchList('*'); +echo "searchList('*'): {$all->getTotalCount()} total\n"; + +$list = $repo->searchList('cookbook'); +$list->page(1); +echo "searchList('cookbook') page 1: " . count($list) . " results\n"; + +$repo->delete($ticket->id); +echo "\nCleaned up.\nDone.\n"; diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md new file mode 100644 index 0000000..57f35ed --- /dev/null +++ b/examples/cookbook/README.md @@ -0,0 +1,44 @@ +# Zammad API PHP Client — Cookbook + +## Setup + +```bash +cp .env.example .env +# Edit .env with your Zammad URL + token +``` + +## Recipes + +| File | Description | +|---|---| +| `00-plain.php` | Guzzle setup (used by recipes 01-06). Standalone, copy-paste-ready. | +| `00-laravel.php` | Laravel service container setup. | +| `00-symfony.php` | Symfony bundle setup. | +| `00-slim.php` | Non-Guzzle setup (Symfony HttpClient + Nyholm PSR-17). | +| `01-quick-start.php` | Client instantiation + find ticket #1. | +| `02-crud.php` | Create, read, and delete tickets. | +| `03-listing.php` | `all()` streaming, `list()` pagination, `totalCount()`. | +| `04-updates.php` | `patch()` partial update + `TicketUpdateDTO`. | +| `05-impersonation.php` | `ImpersonationHandler` for scoped on-behalf-of requests. | +| `06-search.php` | Full-text search via `search()` and `searchList()`. | + +## Run + +```bash +# Single recipe: +php examples/cookbook/01-quick-start.php + +# All recipes: +for f in examples/cookbook/0[1-6]*.php; do echo "=== $f ===" && php "$f"; done +``` + +## Environment + +All recipes accept these environment variables: + +| Variable | Default | +|---|---| +| `ZAMMAD_URL` | `http://localhost:3000` | +| `ZAMMAD_TOKEN` | *(required)* | +| `ZAMMAD_USERNAME` | — (for basic auth fallback) | +| `ZAMMAD_PASSWORD` | — | diff --git a/test/Integration/CookbookIntegrationTest.php b/test/Integration/CookbookIntegrationTest.php new file mode 100644 index 0000000..ae77070 --- /dev/null +++ b/test/Integration/CookbookIntegrationTest.php @@ -0,0 +1,63 @@ +&1', + escapeshellarg($url), + escapeshellarg($token), + escapeshellarg($file), + ); + + exec($command, $output, $exitCode); + + self::assertSame( + 0, + $exitCode, + sprintf( + "Recipe %s failed with exit code %d:\n%s", + basename($file), + $exitCode, + implode("\n", $output), + ), + ); + } + + chdir((string) $cwd); + } +}