File size: 7,153 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
<?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);
    }
}