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
6 changes: 4 additions & 2 deletions config/zammad.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
return [

/** Zammad API Base URL
* Full URL to your Zammad instance including the API prefix, e.g.:
* https://zammad.example.com/api/v1
* Full URL to your Zammad instance, e.g.:
* https://zammad.example.com
*
* The API prefix (`/api/v1`) is appended automatically.
*/
'url' => env('ZAMMAD_URL', 'http://127.0.0.1:8098'),

Expand Down
4 changes: 2 additions & 2 deletions src/Bridge/LaravelServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
* - Set your credentials in `.env`:
*
* ```env
* ZAMMAD_URL=https://zammad.example.com/api/v1
* ZAMMAD_URL=https://zammad.example.com
* ZAMMAD_TOKEN=your-api-token
* ```
*
Expand Down Expand Up @@ -69,7 +69,7 @@
*
* 1. `config/zammad.php` values (after `vendor:publish`)
* 2. `ZAMMAD_URL` / `ZAMMAD_TOKEN` environment variables (`.env`)
* 3. Built-in defaults (`http://127.0.0.1:8098/api/v1`, empty token)
* 3. Built-in defaults (`http://127.0.0.1:8098`, empty token)
*
* @see ZammadClient::withToken()
*/
Expand Down
9 changes: 7 additions & 2 deletions src/Bridge/SymfonyBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
* - Set the environment variables (`.env` or `.env.local`):
*
* ```env
* ZAMMAD_URL=https://zammad.example.com/api/v1
* ZAMMAD_URL=https://zammad.example.com
* ZAMMAD_TOKEN=your-api-token
* ```
*
Expand Down Expand Up @@ -73,14 +73,19 @@ final class SymfonyBundle extends Bundle
public function getContainerExtension(): ?ExtensionInterface
{
return new class implements ExtensionInterface {
/**
* Registers the configured Zammad client with the container.
*
* @param array<int, array<string, mixed>> $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$resolved = [];
foreach ($configs as $config) {
$resolved = array_merge($resolved, $config);
}

$url = $resolved['url'] ?? (string) ($_ENV['ZAMMAD_URL'] ?? 'http://127.0.0.1:8098/api/v1');
$url = $resolved['url'] ?? (string) ($_ENV['ZAMMAD_URL'] ?? 'http://127.0.0.1:8098');
$token = $resolved['token'] ?? (string) ($_ENV['ZAMMAD_TOKEN'] ?? '');

$client = new ZammadClient(
Expand Down
83 changes: 64 additions & 19 deletions src/Core/Transport/RequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Psr\Log\NullLogger;
use ZammadAPIClient\Core\Contracts\RequestHandlerInterface;
use ZammadAPIClient\Exceptions\AuthenticationException;
use ZammadAPIClient\Exceptions\BadRequestException;
use ZammadAPIClient\Exceptions\ForbiddenException;
use ZammadAPIClient\Exceptions\NetworkException;
use ZammadAPIClient\Exceptions\NotFoundException;
Expand All @@ -38,6 +39,8 @@
*/
final class RequestHandler implements RequestHandlerInterface
{
public const API_VERSION = 'v1';

private ClientInterface $httpClient;
private RequestFactoryInterface $requestFactory;
private StreamFactoryInterface $streamFactory;
Expand All @@ -48,7 +51,7 @@ final class RequestHandler implements RequestHandlerInterface
/**
* @param ClientInterface $httpClient PSR-18 client (any implementation).
* @param RequestFactoryInterface $factory PSR-17 factory; must also implement {@see StreamFactoryInterface}.
* @param string $baseUrl Base URL incl. API prefix.
* @param string $baseUrl Base URL; the API prefix (`/api/v1`) is appended if missing.
* @param LoggerInterface $logger PSR-3 logger; defaults to NullLogger.
* @param int $maxRetries Max retries on HTTP 429 (0 = disable).
*/
Expand All @@ -69,10 +72,21 @@ public function __construct(
: $httpClient;
$this->requestFactory = $factory;
$this->streamFactory = $factory;
$this->baseUrl = $baseUrl;
$this->baseUrl = self::normalizeBaseUrl($baseUrl);
$this->logger = $logger;
}

/**
* Ensures the base URL ends with the Zammad API prefix (`/api/v1`).
*
* A trailing `/api` or `/api/vN` is stripped first so the prefix is never
* duplicated and the version is always the one this client targets.
*/
private static function normalizeBaseUrl(string $url): string
{
return preg_replace('#/api(?:/v\d+)?$#', '', rtrim($url, '/')) . '/api/' . self::API_VERSION;
}

/**
* Returns the raw PSR-7 response from the most recent request, or null if
* no request has been made yet.
Expand Down Expand Up @@ -135,7 +149,8 @@ public function getRaw(string $uri, array $query = [], array $headers = []): str
$uri .= '?' . http_build_query($query);
}

$options = !empty($headers) ? ['headers' => $headers] : [];
$headers += ['Accept' => '*/*'];
$options = ['headers' => $headers];

return (string) $this->dispatch('GET', $uri, $options)->getBody();
}
Expand Down Expand Up @@ -248,35 +263,65 @@ private function dispatch(string $method, string $uri, array $options): Response
throw $this->mapError($status, $uri, $response);
}

/**
* Maps an unsuccessful HTTP response to its corresponding domain exception.
*/
private function mapError(int $status, string $uri, ResponseInterface $response): ZammadException
{
$raw = (string) $response->getBody();

return match (true) {
$status === 401 => new AuthenticationException('Invalid credentials'),
$status === 403 => new ForbiddenException("Access denied: {$uri}"),
$status === 404 => new NotFoundException("Resource not found: {$uri}"),
$status === 422 => $this->validationError($response),
$status === 400 => new BadRequestException($this->extractErrorMessage($raw) ?? 'Bad request'),
$status === 401 => new AuthenticationException($this->extractErrorMessage($raw) ?? 'Invalid credentials'),
$status === 403 => new ForbiddenException($this->extractErrorMessage($raw) ?? "Access denied: {$uri}"),
$status === 404 => new NotFoundException($this->extractErrorMessage($raw) ?? "Resource not found: {$uri}"),
$status === 422 => $this->validationError($raw),
$status === 429 => new RateLimitException(
'Too many requests',
(int) ($response->getHeaderLine('Retry-After') ?: 60),
),
$status >= 500 => new ServerErrorException("Server error: {$status}"),
default => new NetworkException("Unexpected status: {$status}"),
$status >= 500 => new ServerErrorException($this->extractErrorMessage($raw) ?? "Server error: {$status}"),
default => new NetworkException($this->extractErrorMessage($raw) ?? "Unexpected status: {$status}"),
};
}

private function validationError(ResponseInterface $response): ValidationException
/**
* Builds a validation exception from a raw HTTP 422 response body.
*/
private function validationError(string $raw): ValidationException
{
$raw = (string) $response->getBody();
return new ValidationException(
$this->extractErrorMessage($raw) ?? $this->extractValidationMessage($raw),
$this->extractValidationErrors($this->decodeLenient($raw)),
);
}

/**
* Extracts a human-readable message from an error response body.
*
* Prefers the JSON `error`/`error_human` fields; falls back to the raw
* body for non-HTML text responses. Returns null when no usable message
* can be extracted (callers then supply a generic fallback).
*/
private function extractErrorMessage(string $raw): ?string
{
if ($raw === '') {
return null;
}

$body = $this->decodeLenient($raw);

$message = is_string($body['error'] ?? null)
? $body['error']
: $this->extractValidationMessage($raw);
$message = $body['error'] ?? $body['error_human'] ?? null;
if (is_string($message) && $message !== '') {
return $message;
}

return new ValidationException(
$message,
$this->extractValidationErrors($body),
);
$trimmed = trim($raw);
if ($trimmed === '' || str_starts_with($trimmed, '<')) {
return null;
}

return substr($trimmed, 0, 200);
}

/**
Expand All @@ -285,7 +330,7 @@ private function validationError(ResponseInterface $response): ValidationExcepti
*/
private function extractValidationErrors(array $body): array
{
$details = $body['details'] ?? $body['error_details'] ?? null;
$details = $body['details'] ?? $body['error_details'] ?? $body['errors'] ?? null;

return is_array($details) ? $details : [];
}
Expand Down
28 changes: 28 additions & 0 deletions src/Exceptions/BadRequestException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace ZammadAPIClient\Exceptions;

/**
* Thrown when the Zammad API returns HTTP 400 Bad Request.
*
* Indicates that the request was malformed in a way Zammad could not process
* (as opposed to a 422 ValidationException, where the payload is syntactically
* valid but fails business-logic validation).
*
* Common causes:
* - Missing or malformed query/body parameters.
* - Invalid filter expressions.
* - Endpoint-specific input that fails pre-validation parsing.
*/
final class BadRequestException extends \RuntimeException implements ZammadException
{
/**
* Creates an HTTP 400 exception with the API-provided message.
*/
public function __construct(string $message = 'Bad request')
{
parent::__construct($message, 400);
}
}
18 changes: 4 additions & 14 deletions src/Factory/GuzzleClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,13 @@ public static function withBasicAuth(
return new self($url, 'Basic ' . base64_encode("{$user}:{$pass}"), $config);
}

/**
* Creates a request handler configured with this factory's credentials.
*/
public function createHandler(): RequestHandlerInterface
{
$config = $this->config ?? new ConnectionConfig();

$url = self::normalizeUrl($this->url);

$httpClient = new GuzzleClient([
'headers' => [
'User-Agent' => self::USER_AGENT,
Expand All @@ -68,20 +69,9 @@ public function createHandler(): RequestHandlerInterface
return new RequestHandler(
$httpClient,
new HttpFactory(),
$url,
$this->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;
}
}
Loading