Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.85% covered (success)
94.85%
92 / 97
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
RemoteInstanceApiClient
94.79% covered (success)
94.79%
91 / 96
72.73% covered (warning)
72.73%
8 / 11
31.14
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 post
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 put
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 buildUrl
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 executeRequest
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 executeCurl
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
5
 executeStream
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
3.02
 processResponsePayload
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 parseResponseStatusCode
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Instance\Infrastructure\Client;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Exception\ValidationException;
12use App\Core\Instance\Domain\Exception\RemoteInstanceApiException;
13use App\Core\Instance\Domain\Model\ClientInstance;
14
15/**
16 * Remote Instance API HTTP Client.
17 *
18 * Dispatches secure, optimized HTTP REST API requests to remote SaaS application instances
19 * using persistent cURL connections with stream context fallback.
20 *
21 * @package App\Core\Instance\Infrastructure\Client
22 */
23final readonly class RemoteInstanceApiClient implements RemoteInstanceApiClientInterface
24{
25    private const int DEFAULT_TIMEOUT_SECONDS = 5;
26    private const int DEFAULT_CONNECT_TIMEOUT_SECONDS = 3;
27
28    /**
29     * RemoteInstanceApiClient constructor.
30     *
31     * @param int $timeoutSeconds Network request timeout in seconds.
32     */
33    public function __construct(
34        private int $timeoutSeconds = self::DEFAULT_TIMEOUT_SECONDS
35    ) {
36    }
37
38    /**
39     * {@inheritdoc}
40     */
41    public function get(ClientInstance $instance, string $endpoint, array $queryParams = []): array
42    {
43        $url = $this->buildUrl($instance, $endpoint, $queryParams);
44        return $this->executeRequest('GET', $url, $instance->apiBearerToken);
45    }
46
47    /**
48     * {@inheritdoc}
49     */
50    public function post(ClientInstance $instance, string $endpoint, array $payload = []): array
51    {
52        $url = $this->buildUrl($instance, $endpoint);
53        return $this->executeRequest('POST', $url, $instance->apiBearerToken, $payload);
54    }
55
56    /**
57     * {@inheritdoc}
58     */
59    public function put(ClientInstance $instance, string $endpoint, array $payload = []): array
60    {
61        $url = $this->buildUrl($instance, $endpoint);
62        return $this->executeRequest('PUT', $url, $instance->apiBearerToken, $payload);
63    }
64
65    /**
66     * {@inheritdoc}
67     */
68    public function delete(ClientInstance $instance, string $endpoint): array
69    {
70        $url = $this->buildUrl($instance, $endpoint);
71        return $this->executeRequest('DELETE', $url, $instance->apiBearerToken);
72    }
73
74    /**
75     * Builds full target URL from base URL, relative endpoint and query params.
76     *
77     * @param ClientInstance       $instance    Target instance.
78     * @param string               $endpoint    Relative path.
79     * @param array<string, mixed> $queryParams Query parameters array.
80     * @return string Fully qualified URL.
81     */
82    private function buildUrl(ClientInstance $instance, string $endpoint, array $queryParams = []): string
83    {
84        $base = rtrim($instance->apiBaseUrl, '/');
85        $path = '/' . ltrim($endpoint, '/');
86        $url = $base . $path;
87
88        if ($queryParams !== []) {
89            $queryString = http_build_query($queryParams);
90            $url .= (str_contains($url, '?') ? '&' : '?') . $queryString;
91        }
92
93        return $url;
94    }
95
96    /**
97     * Executes HTTP request with Bearer authorization and JSON payload handling.
98     *
99     * @param string                    $method      HTTP method (GET, POST, PUT, DELETE).
100     * @param string                    $url         Full request URL.
101     * @param string                    $bearerToken API authorization token.
102     * @param array<string, mixed>|null $payload     Optional body payload.
103     * @return array<string, mixed> Parsed response array.
104     * @throws RemoteInstanceApiException On network or HTTP error.
105     */
106    private function executeRequest(
107        string $method,
108        string $url,
109        string $bearerToken,
110        ?array $payload = null
111    ): array {
112        $headers = [
113            'Accept: application/json',
114            'X-Requested-With: XMLHttpRequest',
115            'X-Admin-Context: 1',
116        ];
117
118        if ($bearerToken !== '') {
119            $headers[] = 'Authorization: Bearer ' . $bearerToken;
120        }
121
122        $bodyJson = null;
123        if ($payload !== null && in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
124            $bodyJson = (string) json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
125            $headers[] = 'Content-Type: application/json';
126            $headers[] = 'Content-Length: ' . strlen($bodyJson);
127        }
128
129        if (extension_loaded('curl')) {
130            return $this->executeCurl($method, $url, $headers, $bodyJson);
131        }
132
133        return $this->executeStream($method, $url, $headers, $bodyJson);
134    }
135
136    /**
137     * Executes request via optimized cURL handle with connection reuse.
138     *
139     * @param string        $method   HTTP method.
140     * @param string        $url      Full request URL.
141     * @param list<string>  $headers  HTTP headers.
142     * @param string|null   $bodyJson JSON body string.
143     * @return array<string, mixed> Decoded JSON response.
144     */
145    private function executeCurl(string $method, string $url, array $headers, ?string $bodyJson): array
146    {
147        $ch = curl_init();
148        curl_setopt_array($ch, [
149            CURLOPT_URL            => $url,
150            CURLOPT_RETURNTRANSFER => true,
151            CURLOPT_CUSTOMREQUEST  => $method,
152            CURLOPT_HTTPHEADER     => $headers,
153            CURLOPT_TIMEOUT        => $this->timeoutSeconds,
154            CURLOPT_CONNECTTIMEOUT => self::DEFAULT_CONNECT_TIMEOUT_SECONDS,
155            CURLOPT_TCP_KEEPALIVE  => 1,
156            CURLOPT_TCP_KEEPIDLE   => 120,
157            CURLOPT_TCP_KEEPINTVL  => 60,
158            CURLOPT_SSL_VERIFYPEER => true,
159            CURLOPT_SSL_VERIFYHOST => 2,
160        ]);
161
162        if ($bodyJson !== null) {
163            curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
164        }
165
166        /** @var string|false $responseBody */
167        $responseBody = curl_exec($ch);
168        $statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
169        $curlError = curl_error($ch);
170        curl_close($ch);
171
172        if ($responseBody === false || $statusCode === 0) {
173            $msg = $curlError !== '' ? $curlError : 'Network transport error via cURL';
174            throw RemoteInstanceApiException::forConnectionFailure($url, $msg);
175        }
176
177        return $this->processResponsePayload($statusCode, $responseBody, $url);
178    }
179
180    /**
181     * Fallback execution via PHP stream context.
182     *
183     * @param string        $method   HTTP method.
184     * @param string        $url      Full request URL.
185     * @param list<string>  $headers  HTTP headers.
186     * @param string|null   $bodyJson JSON body string.
187     * @return array<string, mixed> Decoded JSON response.
188     */
189    private function executeStream(string $method, string $url, array $headers, ?string $bodyJson): array
190    {
191        $contextOptions = [
192            'http' => [
193                'method'          => $method,
194                'header'          => implode("\r\n", $headers),
195                'timeout'         => $this->timeoutSeconds,
196                'ignore_errors'   => true,
197                'follow_location' => 0,
198                'max_redirects'   => 0,
199            ],
200            'ssl' => [
201                'verify_peer'       => true,
202                'verify_peer_name'  => true,
203                'allow_self_signed' => false,
204            ],
205        ];
206
207        if ($bodyJson !== null) {
208            $contextOptions['http']['content'] = $bodyJson;
209        }
210
211        $context = stream_context_create($contextOptions);
212        $responseBody = @file_get_contents($url, false, $context);
213
214        if ($responseBody === false) {
215            $lastError = error_get_last();
216            $msg = $lastError['message'] ?? 'Network transport error via stream';
217            throw RemoteInstanceApiException::forConnectionFailure($url, $msg);
218        }
219
220        $statusCode = $this->parseResponseStatusCode($http_response_header);
221        return $this->processResponsePayload($statusCode, $responseBody, $url);
222    }
223
224    /**
225     * Handles HTTP status code validation and parses JSON payload.
226     *
227     * @param int    $statusCode   HTTP response code.
228     * @param string $responseBody Raw response body.
229     * @param string $url          Target request URL.
230     * @return array<string, mixed> Parsed response array.
231     */
232    private function processResponsePayload(int $statusCode, string $responseBody, string $url): array
233    {
234        if ($statusCode === 422) {
235            $decoded = json_decode($responseBody, true);
236            $errors = is_array($decoded)
237                ? ($decoded['invalid_params'] ?? $decoded['errors'] ?? ['validation' => 'Validation error'])
238                : ['validation' => 'Validation error'];
239            throw new ValidationException(is_array($errors) ? $errors : [(string) $errors]);
240        }
241
242        if ($statusCode >= 400) {
243            throw RemoteInstanceApiException::forHttpError($url, $statusCode, $responseBody);
244        }
245
246        $decoded = json_decode($responseBody, true);
247        return is_array($decoded) ? $decoded : ['raw' => $responseBody];
248    }
249
250    /**
251     * Extracts HTTP status code integer from response headers list.
252     *
253     * @param list<string> $headers Response headers array.
254     * @return int Extracted HTTP status code or 200 fallback.
255     */
256    private function parseResponseStatusCode(array $headers): int
257    {
258        if ($headers === []) {
259            return 200;
260        }
261
262        for ($i = count($headers) - 1; $i >= 0; $i--) {
263            if (preg_match('#^HTTP/\S+\s+(\d{3})#i', $headers[$i], $matches)) {
264                return (int) $matches[1];
265            }
266        }
267
268        return 200;
269    }
270}