From d998f4f59ca22131c94f64518774b613eb103387 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 13 Jul 2026 20:49:17 +0200 Subject: [PATCH] feat(S4): Add ZammadClient entry point with connect() factory (token, OAuth2, basic auth), __call() Ruby-style repository accessor, setOnBehalfOfUser/performOnBehalfOf impersonation EPIC-#79 #116 #117 #118 #119 #120 --- src/ZammadClient.php | 183 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/ZammadClient.php 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; + } +}