qwen_2.5_model / sdk /php /src /Http /HttpExecutor.php
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
7.15 kB
<?php
declare(strict_types=1);
namespace OpenWA\Http;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ConnectException;
use OpenWA\Exceptions\OpenWAApiException;
use OpenWA\Exceptions\OpenWATimeoutException;
use Psr\Http\Message\ResponseInterface;
/**
* Injectable HTTP transport for the OpenWA SDK.
*
* The client never builds a bare Guzzle client with a hard-coded handler.
* Instead it accepts an optional Guzzle {@see ClientInterface} (defaulting to a
* new Guzzle client), and for testing a Guzzle {@see \GuzzleHttp\Handler\MockHandler}
* is injected via the ``httpClient`` option — no global monkey-patching.
*/
class HttpExecutor
{
private ClientInterface $http;
private float $timeout;
private string $apiKey;
private string $baseUrl;
/** @var array<string,string> */
private array $defaultHeaders;
/**
* @param array<string,string> $defaultHeaders Applied UNDER the auth/JSON headers
* (which always win), on every request.
*/
public function __construct(
string $baseUrl,
string $apiKey,
float $timeout = 30.0,
?ClientInterface $httpClient = null,
array $defaultHeaders = []
) {
$this->timeout = $timeout;
$this->apiKey = $apiKey;
$this->baseUrl = rtrim($baseUrl, '/');
$this->defaultHeaders = $defaultHeaders;
// Auth/JSON headers are applied per-request (in request()). Request URLs
// are built absolute (baseUrl . path) so a base path prefix (e.g. /v1
// behind a reverse proxy) is preserved; base_uri is intentionally unset,
// because an absolute request path would otherwise replace it.
$this->http = $httpClient ?? new \GuzzleHttp\Client([
'timeout' => $timeout,
]);
}
/**
* Percent-encode a single path segment (e.g. a chat/message id) so a value
* containing /, # or ? can't break out of its path position. WhatsApp-id
* characters that are already path-safe (@, :, +) are kept readable.
*/
public function encodeSegment(string $segment): string
{
return str_replace(['%40', '%3A', '%2B'], ['@', ':', '+'], rawurlencode($segment));
}
/**
* Perform one request and return the decoded JSON body (or null for 204).
*
* @param string $method HTTP method.
* @param string $path Path beginning with /, e.g. /api/sessions.
* @param array<string,mixed> $query Query parameters (null values skipped).
* @param mixed|null $body JSON-serializable request body.
*
* @return mixed Decoded JSON, or null for empty/204 responses.
*
* @throws OpenWAApiException On any non-2xx response (typed subclass).
* @throws OpenWATimeoutException On timeout.
*/
public function request(string $method, string $path, array $query = [], $body = null)
{
// Auth/JSON headers are applied per-request so they are correct whether
// a default or injected client is used (and never leak Guzzle exceptions:
// http_errors disabled so we translate status into typed SDK exceptions).
$options = [
'http_errors' => false,
// Per-request options override injected Guzzle client defaults, so the SDK's configured
// timeout is enforced consistently on both default and caller-supplied clients.
'timeout' => $this->timeout,
// Never auto-follow redirects: doing so would re-send the X-API-Key
// header to the redirect target (potentially a different origin).
'allow_redirects' => false,
// Caller default headers first; auth/JSON win so they can't be clobbered.
'headers' => array_merge($this->defaultHeaders, [
'X-API-Key' => $this->apiKey,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
]),
];
// Build query string, skipping null values (so absent optionals aren't sent).
$query = array_filter($query, fn ($v) => $v !== null);
if ($query !== []) {
$options['query'] = $query;
}
if ($body !== null) {
$options['json'] = $body;
}
try {
$response = $this->http->request($method, $this->baseUrl . $path, $options);
} catch (ConnectException $e) {
// cURL error 28 (CURLE_OPERATION_TIMEDOUT) is the canonical timeout
// signal, surfaced via the handler context. We check errno first
// (locale- and version-independent) and fall back to message matching
// for transports that don't populate the context (e.g. stream handler).
$errno = $e->getHandlerContext()['errno'] ?? null;
$isTimeout = $errno === 28 || str_contains($e->getMessage(), 'timed out');
if ($isTimeout) {
throw new OpenWATimeoutException($this->timeout);
}
throw $e;
}
$status = $response->getStatusCode();
// Treat any non-2xx as an error, including 3xx: redirects are deliberately not followed (so
// the API key is never re-sent to the target), making an unfollowed 3xx unusable rather than
// a success. Matches the JS (`!res.ok`) and Python (`>= 300`) transports.
if ($status >= 300) {
throw $this->buildApiException($response, $method, $path);
}
$text = (string) $response->getBody();
if ($status === 204 || $text === '') {
return null;
}
$decoded = json_decode($text, true);
return $decoded === null && json_last_error() !== JSON_ERROR_NONE ? $text : $decoded;
}
private function buildApiException(ResponseInterface $response, string $method, string $path): OpenWAApiException
{
$status = $response->getStatusCode();
$text = (string) $response->getBody();
$data = null;
if ($text !== '') {
$decoded = json_decode($text, true);
$data = ($decoded === null && json_last_error() !== JSON_ERROR_NONE) ? $text : $decoded;
}
// NestJS envelope: {statusCode, message, error}. `error` is sometimes absent
// (e.g. some 500s), so detect on statusCode + message and treat error as optional.
$envelope = is_array($data) && isset($data['statusCode'], $data['message']) ? $data : null;
$rawMessage = $envelope['message'] ?? $data;
if (is_array($rawMessage)) {
$messageText = implode(', ', array_map('strval', $rawMessage));
} elseif (is_string($rawMessage)) {
$messageText = $rawMessage;
} else {
$messageText = $rawMessage === null ? $response->getReasonPhrase() : (string) $rawMessage;
}
$reason = $response->getReasonPhrase();
$message = "OpenWA API {$status} {$reason}{$method} {$path}: {$messageText}";
return OpenWAApiException::classify($status, $message, $data, $envelope['error'] ?? null);
}
}