diff --git a/src/ZammadClient.php b/src/ZammadClient.php new file mode 100644 index 0000000..ff7fe5f --- /dev/null +++ b/src/ZammadClient.php @@ -0,0 +1,183 @@ + */ + private array $repos = []; + + public function __construct( + private RequestHandlerInterface $handler, + ) { + } + + /** + * Convenience factory with multiple authentication modes. + * + * Token auth (preferred): + * ZammadClient::connect($url, token: 'your-token') + * + * OAuth2: + * ZammadClient::connect($url, oauth2: 'your-token') + * + * Basic auth: + * ZammadClient::connect($url, user: 'admin@example.com', pass: 'test') + * + * @param bool $debug Enables Guzzle debug mode (request/response body to stdout). + * @param bool $verifySsl false to disable TLS certificate verification. + */ + public static function connect( + string $url, + ?string $token = null, + ?string $oauth2 = null, + ?string $user = null, + ?string $pass = null, + int $maxRetries = 3, + bool $debug = false, + bool $verifySsl = true, + ): self { + $headers = ['User-Agent' => self::USER_AGENT]; + + if ($token !== null && $token !== '' && $token !== '0') { + $headers['Authorization'] = "Token token={$token}"; + } elseif ($oauth2 !== null && $oauth2 !== '' && $oauth2 !== '0') { + $headers['Authorization'] = "Bearer {$oauth2}"; + } elseif ($user !== null && $pass !== null) { + $headers['Authorization'] = 'Basic ' . base64_encode("{$user}:{$pass}"); + } else { + throw new InvalidArgumentException( + 'Provide one of: token, oauth2, or user+pass for authentication.' + ); + } + + $http = new RetryAfterMiddleware( + new GuzzleClient([ + 'headers' => $headers, + 'debug' => $debug, + 'verify' => $verifySsl, + ]), + maxRetries: $maxRetries, + ); + + $factory = new HttpFactory(); + $handler = new RequestHandler($http, $factory, $factory, $url); + + return new self($handler); + } + + /** + * Activates API-level impersonation for all subsequent requests. + * + * Forwards $userId as X-On-Behalf-Of header. The header stays active + * until unsetOnBehalfOfUser() is called. + */ + public function setOnBehalfOfUser(?int $userId): void + { + $this->handler->setOnBehalfOfUser($userId); + } + + public function unsetOnBehalfOfUser(): void + { + $this->handler->setOnBehalfOfUser(null); + } + + /** + * 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 cleared afterwards. + */ + public function performOnBehalfOf(int $userId, callable $callback): mixed + { + $previous = null; + + $this->handler->setOnBehalfOfUser($userId); + + try { + return $callback($this); + } finally { + $this->handler->setOnBehalfOfUser(null); + } + } + + /** + * 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 + * + * @param array $args + */ + public function __call(string $name, array $args): AbstractRepository + { + $camel = implode('', array_map('ucfirst', explode('_', $name))); + + foreach (RepositoryRegistry::DEFINITIONS as $repoClass => $def) { + $dtoShort = substr(strrchr($def['dto'], '\\') ?: '', 1); + $repoShort = substr(strrchr($repoClass, '\\') ?: '', 1); + + if ( + strcasecmp($dtoShort, $camel . 'DTO') === 0 + || strcasecmp($repoShort, $camel . 'Repository') === 0 + || strcasecmp($dtoShort, $camel) === 0 + ) { + return $this->repo($repoClass); + } + } + + throw new InvalidArgumentException("Unknown resource: {$name}"); + } + + /** + * 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 + { + $definition = RepositoryRegistry::definition($repositoryClass); + + /** @var T */ + return $this->repository($repositoryClass, $definition['path'], $definition['dto']); + } + + /** + * @template T of AbstractRepository + * @param class-string $repoClass + * @param class-string $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; + } +} diff --git a/test/Integration/TicketIntegrationTest.php b/test/Integration/TicketIntegrationTest.php new file mode 100644 index 0000000..4966a26 --- /dev/null +++ b/test/Integration/TicketIntegrationTest.php @@ -0,0 +1,106 @@ +ticket()->create(new TicketDTO( + id: null, + group_id: 1, + priority_id: 2, + state_id: 1, + organization_id: null, + customer_id: null, + owner_id: null, + title: 'Integration Test ' . uniqid('', true), + number: null, + created_at: null, + updated_at: null, + )); + + self::assertGreaterThan(0, $ticket->id); + self::assertEquals(1, $ticket->group_id); + self::assertNotEmpty($ticket->title); + + self::$createdIds[] = $ticket->id; + } + + public function testFindTicket(): void + { + if (empty(self::$createdIds)) { + $this->markTestSkipped('No ticket created — run testCreateTicket first.'); + } + + $id = self::$createdIds[0]; + $ticket = self::$client->ticket()->find($id); + + self::assertEquals($id, $ticket->id); + self::assertNotEmpty($ticket->title); + } + + public function testPatchTicket(): void + { + if (empty(self::$createdIds)) { + $this->markTestSkipped('No ticket created — run testCreateTicket first.'); + } + + $id = self::$createdIds[0]; + $newTitle = 'Patched ' . uniqid('', true); + + $patched = self::$client->ticket()->patch($id, ['title' => $newTitle]); + self::assertEquals($newTitle, $patched->title); + } + + public function testDeleteTicket(): void + { + if (empty(self::$createdIds)) { + $this->markTestSkipped('No ticket created — run testCreateTicket first.'); + } + + $id = self::$createdIds[0]; + self::$client->ticket()->delete($id); + + $this->expectException(NotFoundException::class); + self::$client->ticket()->find($id); + } +} diff --git a/test/Unit/ZammadClientTest.php b/test/Unit/ZammadClientTest.php new file mode 100644 index 0000000..b8829e5 --- /dev/null +++ b/test/Unit/ZammadClientTest.php @@ -0,0 +1,39 @@ +repo(TicketRepository::class); + $second = $client->repo(TicketRepository::class); + + self::assertSame($first, $second); + self::assertInstanceOf(TicketRepository::class, $first); + } + + public function testRepoThrowsForUnknownRepositoryClass(): void + { + $handler = Mockery::mock(RequestHandlerInterface::class); + $client = new ZammadClient($handler); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown repository'); + + $client->repo('NotARepository'); + } +}