Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions src/ZammadClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient;

use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Psr7\HttpFactory;
use InvalidArgumentException;
use ZammadAPIClient\Core\Contracts\ClientInterface;
use ZammadAPIClient\Core\Contracts\DTOInterface;
use ZammadAPIClient\Core\Contracts\RequestHandlerInterface;
use ZammadAPIClient\Core\AbstractRepository;
use ZammadAPIClient\Core\RepositoryRegistry;
use ZammadAPIClient\Core\RequestHandler;
use ZammadAPIClient\Core\RetryAfterMiddleware;

final class ZammadClient implements ClientInterface
{
public const USER_AGENT = 'Zammad API PHP';

/** @var array<class-string, object> */
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<array-key, mixed> $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<T> $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<T> $repoClass
* @param class-string<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;
}
}
106 changes: 106 additions & 0 deletions test/Integration/TicketIntegrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient\Tests\Integration;

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use ZammadAPIClient\Endpoints\Tickets\TicketDTO;
use ZammadAPIClient\Exceptions\NotFoundException;
use ZammadAPIClient\ZammadClient;

#[Group('integration')]
final class TicketIntegrationTest extends TestCase
{
private static ZammadClient $client;
private static array $createdIds = [];

public static function setUpBeforeClass(): void
{
$url = getenv('ZAMMAD_URL') ?: null;
$token = getenv('ZAMMAD_TOKEN') ?: null;
$user = getenv('ZAMMAD_USER') ?: null;
$pass = getenv('ZAMMAD_PASS') ?: null;

if ($url === null || $url === '') {
self::markTestSkipped(
'Integration tests require ZAMMAD_URL environment variable. '
. 'Run: ZAMMAD_URL=http://127.0.0.1:8080/api/v1 ZAMMAD_TOKEN=xxx vendor/bin/phpunit --group=integration'
);
}

if ($token !== null && $token !== '') {
self::$client = ZammadClient::connect($url, token: $token);
} elseif ($user !== null && $pass !== null && $user !== '' && $pass !== '') {
self::$client = ZammadClient::connect($url, user: $user, pass: $pass);
} else {
self::markTestSkipped(
'Integration tests require ZAMMAD_TOKEN or ZAMMAD_USER+ZAMMAD_PASS environment variables. '
. 'Run: ZAMMAD_URL=http://127.0.0.1:8080/api/v1 ZAMMAD_TOKEN=xxx vendor/bin/phpunit --group=integration'
);
}
}

public function testCreateTicket(): void
{
$ticket = self::$client->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);
}
}
39 changes: 39 additions & 0 deletions test/Unit/ZammadClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient\Tests\Unit;

use Mockery;
use Mockery\Adapter\Phpunit\MockeryTestCase;
use PHPUnit\Framework\Attributes\Group;
use ZammadAPIClient\Core\Contracts\RequestHandlerInterface;
use ZammadAPIClient\Endpoints\Tickets\TicketRepository;
use ZammadAPIClient\ZammadClient;

#[Group('unit')]
final class ZammadClientTest extends MockeryTestCase
{
public function testRepoReturnsMemoizedRepositoryInstance(): void
{
$handler = Mockery::mock(RequestHandlerInterface::class);
$client = new ZammadClient($handler);

$first = $client->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');
}
}
Loading