diff --git a/src/Core/Contracts/ClientInterface.php b/src/Core/Contracts/ClientInterface.php new file mode 100644 index 0000000..2ff4925 --- /dev/null +++ b/src/Core/Contracts/ClientInterface.php @@ -0,0 +1,19 @@ + $repositoryClass + * @return T + */ + public function repo(string $repositoryClass): AbstractRepository; +} diff --git a/src/Core/Contracts/RequestHandlerInterface.php b/src/Core/Contracts/RequestHandlerInterface.php new file mode 100644 index 0000000..26eb3a3 --- /dev/null +++ b/src/Core/Contracts/RequestHandlerInterface.php @@ -0,0 +1,109 @@ + $options PSR-18 client options (e.g. `['json' => [...]]`). + * @return array JSON-decoded response body. + * @throws \ZammadAPIClient\Exceptions\AuthenticationException For 401 responses. + * @throws \ZammadAPIClient\Exceptions\NotFoundException For 404 responses. + * @throws \ZammadAPIClient\Exceptions\ValidationException For 422 responses. + * @throws \ZammadAPIClient\Exceptions\RateLimitException For 429 responses (after retries). + * @throws \ZammadAPIClient\Exceptions\ServerErrorException For 5xx responses. + * @throws \ZammadAPIClient\Exceptions\NetworkException On transport-level failures. + */ + public function request( + string $method, + string $uri, + array $options = [], + ): array; + + /** + * Performs a GET request and returns the raw response body as a string. + * + * Use this instead of {@see self::get()} when the endpoint returns binary + * data (e.g. ticket attachment content) that cannot be JSON-decoded. + * + * @param array $query URL query parameters. + * @throws \ZammadAPIClient\Exceptions\NetworkException On transport failure. + */ + public function getRaw(string $uri, array $query = []): string; + + /** + * Performs a GET request and returns the decoded JSON body. + * + * @param array $query URL query parameters appended to the URI. + * @return array + */ + public function get(string $uri, array $query = []): array; + + /** + * Performs a POST request with a JSON body and returns the decoded response. + * + * @param array $body Request payload; serialised to JSON automatically. + * @return array + */ + public function post(string $uri, array $body = []): array; + + /** + * Performs a PUT request with a JSON body and returns the decoded response. + * + * @param array $body Request payload; serialised to JSON automatically. + * @return array + */ + public function put(string $uri, array $body = []): array; + + /** + * Performs a DELETE request and returns the decoded JSON body. + * + * Zammad's DELETE endpoints return the deleted resource's data, which + * callers may use to confirm the operation. + * + * @return array + */ + public function delete(string $uri): array; + + /** + * Returns the raw PSR-7 response of the most recent request, or null. + * + * Useful for inspecting response headers (e.g. `X-Total-Count` for + * pagination) after a repository call. + */ + public function getLastResponse(): ?ResponseInterface; + + /** + * Sets or clears the user ID for API impersonation. + * + * When non-null the value is forwarded as the `X-On-Behalf-Of` HTTP header, + * causing Zammad to execute the request as the given user. Pass null to + * remove impersonation. + * + * @see https://docs.zammad.org/en/latest/api/intro.html#x-on-behalf-of + */ + public function setOnBehalfOfUser(?int $userId): void; +} diff --git a/src/Core/RequestHandler.php b/src/Core/RequestHandler.php new file mode 100644 index 0000000..86f9ccd --- /dev/null +++ b/src/Core/RequestHandler.php @@ -0,0 +1,280 @@ +lastResponse; + } + + /** + * Activates or deactivates API-level user impersonation. + * + * When $userId is non-null it is forwarded as the `X-On-Behalf-Of` HTTP + * header on every subsequent request, causing Zammad to execute actions + * as the given agent. Pass null to disable impersonation. + * + * @see https://docs.zammad.org/en/latest/api/intro.html#x-on-behalf-of + */ + public function setOnBehalfOfUser(?int $userId): void + { + $this->onBehalfOfUser = $userId; + } + + /** + * Dispatches an HTTP request and returns the JSON-decoded body as an array. + * + * Error mapping happens inside {@see self::dispatch()} before JSON decoding, + * so callers never receive a response with a 4xx/5xx body — an exception is + * thrown instead. An empty response body is treated as an empty array (some + * Zammad DELETE endpoints return 200 with no body). + * + * @param array $options Raw PSR-18 options forwarded verbatim to the HTTP client. + * @return array + */ + public function request( + string $method, + string $uri, + array $options = [], + ): array { + $response = $this->dispatch($method, $uri, $options); + $body = (string) $response->getBody(); + + if ($body === '') { + return []; + } + + /** @var mixed $decoded */ + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + + return is_array($decoded) ? $decoded : []; + } + + /** + * Performs a GET request and returns the unprocessed response body. + * + * 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. + */ + public function getRaw(string $uri, array $query = []): string + { + if (!empty($query)) { + $uri .= '?' . http_build_query($query); + } + + return (string) $this->dispatch('GET', $uri, [])->getBody(); + } + + /** + * Performs a GET request and returns the decoded JSON body. + * + * Query parameters are serialised with {@see http_build_query()} and + * appended to the URI; they are not sent as a request body. + * + * @param array $query URL query parameters. + * @return array + */ + public function get(string $uri, array $query = []): array + { + if (!empty($query)) { + $uri .= '?' . http_build_query($query); + } + + return $this->request('GET', $uri); + } + + /** + * Performs a POST request with a JSON body and returns the decoded response. + * + * $body is serialised to JSON and sent with `Content-Type: application/json`. + * Use this for resource creation (Zammad convention: POST → 201 or 200 with body). + * + * @param array $body Payload fields; serialised to JSON automatically. + * @return array + */ + public function post(string $uri, array $body = []): array + { + return $this->request('POST', $uri, [ + 'headers' => ['Content-Type' => 'application/json'], + 'body' => json_encode($body), + ]); + } + + /** + * Performs a PUT request with a JSON body and returns the decoded response. + * + * Used for both full resource replacements (update) and partial updates + * (patch), since Zammad uses PUT for both semantics. + * + * @param array $body Payload fields; serialised to JSON automatically. + * @return array + */ + public function put(string $uri, array $body = []): array + { + return $this->request('PUT', $uri, [ + 'headers' => ['Content-Type' => 'application/json'], + 'body' => json_encode($body), + ]); + } + + /** + * Performs a DELETE request and returns the decoded JSON body. + * + * Zammad's API returns the deleted resource's state in the response body, + * which can be used as a confirmation receipt. An empty body returns `[]`. + * + * @return array + */ + public function delete(string $uri): array + { + return $this->request('DELETE', $uri); + } + + /** + * Performs the HTTP request and maps error status codes to typed + * exceptions - before the body is interpreted as JSON. + * + * @param array $options + */ + private function dispatch(string $method, string $uri, array $options): ResponseInterface + { + $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']['X-On-Behalf-Of'] = (string) $this->onBehalfOfUser; + } + + try { + $request = $this->requestFactory->createRequest($method, $fullUri); + + if (isset($options['headers']) && is_array($options['headers'])) { + foreach ($options['headers'] as $name => $value) { + $request = $request->withHeader($name, $value); + } + } + + if (isset($options['body']) && $options['body'] !== null) { + $body = is_string($options['body']) ? $options['body'] : json_encode($options['body']); + $request = $request->withBody($this->streamFactory->createStream((string) $body)); + } + + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new NetworkException($e->getMessage(), previous: $e); + } + + $this->lastResponse = $response; + $status = $response->getStatusCode(); + + if ($status >= 200 && $status < 300) { + return $response; + } + + throw $this->mapError($status, $uri, $response); + } + + private function mapError(int $status, string $uri, ResponseInterface $response): ZammadException + { + return match (true) { + $status === 401 => new AuthenticationException('Invalid credentials'), + $status === 404 => new NotFoundException("Resource not found: {$uri}"), + $status === 422 => $this->validationError($response), + $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}"), + }; + } + + private function validationError(ResponseInterface $response): ValidationException + { + $body = $this->decodeLenient((string) $response->getBody()); + + return new ValidationException( + is_string($body['error'] ?? null) ? $body['error'] : 'Validation failed', + is_array($body['details'] ?? null) ? $body['details'] : [], + ); + } + + /** @return array */ + private function decodeLenient(string $body): array + { + if ($body === '') { + return []; + } + + /** @var mixed $decoded */ + $decoded = json_decode($body, true); + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/src/Core/RetryAfterMiddleware.php b/src/Core/RetryAfterMiddleware.php new file mode 100644 index 0000000..e575421 --- /dev/null +++ b/src/Core/RetryAfterMiddleware.php @@ -0,0 +1,66 @@ +next->sendRequest($request); + + if ($response->getStatusCode() !== 429) { + return $response; + } + + $retryAfter = (int) ($response->getHeaderLine('Retry-After') ?: $this->defaultDelay); + sleep($retryAfter); + $attempt++; + } while ($attempt < $this->maxRetries); + + return $response; + } +}