Lines 84.09% 111 / 132
Methods 61.53% 8 / 13
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 defaultUserAgent 100.00% 1 / 1 100.00% 1 / 1 2
 __construct 92.85% 13 / 14 0.00% 0 / 1 3.00
 sendRequest 100.00% 26 / 26 100.00% 1 / 1 8
 get 100.00% 5 / 5 100.00% 1 / 1 3
 post 100.00% 1 / 1 100.00% 1 / 1 1
 put 100.00% 1 / 1 100.00% 1 / 1 1
 patch 100.00% 1 / 1 100.00% 1 / 1 1
 delete 100.00% 1 / 1 100.00% 1 / 1 1
 head 60.00% 3 / 5 0.00% 0 / 1 3.58
 sendWithBody 100.00% 15 / 15 100.00% 1 / 1 4
 validateConfig 81.25% 26 / 32 0.00% 0 / 1 30.46
 validateOptions 56.00% 14 / 25 0.00% 0 / 1 68.06
 assertSafeHeaders 80.00% 4 / 5 0.00% 0 / 1 6.29
48final class Client implements ClientInterface
49{
50    /** @var string Option key for the sink (file path, resource, or stream) */
51    public const OPTION_SINK = 'sink';
52
53    /** @var string Option key for per-request timeout */
54    public const OPTION_TIMEOUT = 'timeout';
55
56    /** @var string Option key for per-request SSL verification */
57    public const OPTION_VERIFY_SSL = 'verify_ssl';
58
59    /** @var string Option key for per-request headers */
60    public const OPTION_HEADERS = 'headers';
61
62    /** @var string Option key for per-request query params */
63    public const OPTION_QUERY = 'query';
64
65    /** @var string Option key for per-request curl options */
66    public const OPTION_CURL = 'curl';
67
68    /** @var string Option key for per-request user agent */
69    public const OPTION_USER_AGENT = 'user_agent';
70
71    /** @var string Option key for per-request basic auth */
72    public const OPTION_BASIC_AUTH = 'basic_auth';
73
74    /** @var string Option key for a per-request progress callback */
75    public const OPTION_PROGRESS = 'progress';
76
77    /** @var string Option key for streaming the response body */
78    public const OPTION_STREAM = 'stream';
79
80    /**
81     * Generate the default User-Agent string.
82     *
83     * Used as the client's config default and by handlers as a fallback when
84     * the merged options omit `user_agent` (e.g. when a handler is used
85     * directly). Centralized here so every transport sends the same value.
86     * 
87     * @return string The default User-Agent string of a Client.
88     */
89    public static function defaultUserAgent(): string
90    {
91        return 'Lucent-HttpClient/' . (defined('VERSION') ? VERSION : 'unknown');
92    }
93
94    /** @var UriInterface|null Normalized base URI */
95    private readonly ?UriInterface $baseUri;
96
97    /** @var int Default timeout in seconds */
98    private readonly int $timeout;
99
100    /** @var bool Whether to verify SSL certificates */
101    private readonly bool $verifySsl;
102
103    /** @var array{0: string, 1: string}|null Basic auth credentials */
104    private readonly ?array $basicAuth;
105
106    /** @var string User agent */
107    private readonly string $userAgent;
108
109    /** @var array<string, string> Default headers */
110    private readonly array $headers;
111
112    /** @var array<int, mixed> Additional cURL options */
113    private readonly array $curlOptions;
114
115    /** @var HttpFactory PSR-17 factory used to build requests/streams */
116    private readonly HttpFactory $factory;
117
118    /** @var HandlerInterface The default (cURL-backed) transport */
119    private readonly HandlerInterface $defaultHandler;
120
121    /** @var HandlerInterface The streaming transport */
122    private readonly HandlerInterface $streamHandler;
123
124    /**
125     * @param array<string, mixed> $config Client configuration
126     * @param HandlerInterface|null $defaultHandler The default (cURL) transport
127     * @param HandlerInterface|null $streamHandler The streaming transport
128     * @throws \InvalidArgumentException On unknown keys, wrong types, or invalid values
129     */
130    public function __construct(
131        array $config = [],
132        ?HandlerInterface $defaultHandler = null,
133        ?HandlerInterface $streamHandler = null
134    ) {
135        $this->validateConfig($config);
136
137        $baseUri = $config['base_uri'] ?? null;
138        $this->baseUri = $baseUri instanceof UriInterface
139            ? $baseUri
140            : ($baseUri !== null ? Uri::fromString($baseUri) : null);
141
142        $this->timeout = $config['timeout'] ?? 30;
143        $this->verifySsl = $config['verify_ssl'] ?? true;
144        $this->basicAuth = $config['basic_auth'] ?? null;
145        $this->userAgent = $config['user_agent'] ?? self::defaultUserAgent();
146        $this->headers = $config['headers'] ?? [];
147        $this->curlOptions = $config['curl_options'] ?? [];
148        $this->factory = new HttpFactory();
149
150        $this->defaultHandler = $defaultHandler ?? new CurlHandler();
151        $this->streamHandler = $streamHandler ?? new StreamHandler();
152    }
153
154    /**
155     * Send a PSR-7 request and return a PSR-7 response.
156     *
157     * Per-request options may be passed as the second argument:
158     *
159     * ```php
160     * $response = $client->sendRequest($request, [
161     *     'sink'      => '/tmp/file.bin',
162     *     'timeout'   => 5,
163     *     'verify_ssl'=> false,
164     *     'stream'    => true,
165     * ]);
166     * ```
167     *
168     * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, query, curl, user_agent, basic_auth, stream, progress).
169     *     NOTE: this second parameter is a Lucent extension â€” the PSR-18
170     *     interface defines only the $request parameter. Callers relying on
171     *     strict PSR-18 interop should not pass $options.
172     * @throws \Psr\Http\Client\RequestExceptionInterface On request-level failures (invalid request, JSON-encode failure, cURL init failure)
173     * @throws \Psr\Http\Client\NetworkExceptionInterface On transport-level failures (DNS, connection, timeout)
174     * @throws \Psr\Http\Client\ClientExceptionInterface Parent interface of both of the above
175     */
176    public function sendRequest(RequestInterface $request, array $options = []): ResponseInterface
177    {
178        $this->validateOptions($options);
179
180        // Resolve the full URL against the configured base URI. Only relative
181        // URIs (no scheme) are resolved; absolute URIs override the base.
182        $uri = $request->getUri();
183        if ($this->baseUri !== null && $uri->getScheme() === '' && $uri->getHost() === '') {
184            $uri = UriResolver::resolve($this->baseUri, $uri);
185            $request = $request->withUri($uri);
186        }
187
188        // SSRF guard: only http/https are permitted. Rejecting other schemes
189        // (file://, gopher://, dict://, ftp://) at the client boundary stops
190        // local-file disclosure and protocol abuse when a caller passes a
191        // user-supplied URL into the client.
192        $scheme = strtolower($uri->getScheme());
193        if ($scheme !== '' && !in_array($scheme, ['http', 'https'], true)) {
194            throw new RequestException(
195                "Unsupported URL scheme '{$scheme}' â€” only http and https are allowed.",
196                $request
197            );
198        }
199
200        $streaming = ($options['stream'] ?? false) === true;
201
202        // Merge config defaults into the options (per-request wins). The
203        // associative `headers` and `curl` options are deep-merged so
204        // per-request keys add to (not replace) config defaults. Config curl
205        // defaults are skipped on the stream path so a streaming request is
206        // not rejected for curl options it never asked for.
207        $options = array_merge([
208            'timeout' => $this->timeout,
209            'verify_ssl' => $this->verifySsl,
210            'user_agent' => $this->userAgent,
211            'basic_auth' => $this->basicAuth,
212            'headers' => [],
213            'curl' => [],
214        ], $options);
215
216        $options['headers'] = array_merge($this->headers, $options['headers'] ?? []);
217        if (!$streaming) {
218            // array_replace (not array_merge) â€” CURLOPT_* keys are integers
219            // and array_merge would renumber them, breaking the conflict
220            // check and curl_setopt_array.
221            $options['curl'] = array_replace($this->curlOptions, $options['curl'] ?? []);
222        }
223
224        // Dispatch per-request: stream => true routes to the streaming
225        // handler; everything else uses the default (cURL) handler.
226        $handler = $streaming ? $this->streamHandler : $this->defaultHandler;
227
228        // Let the handler validate its own options (conflicting curl options,
229        // unsupported options) against the merged options.
230        $handler->validateOptions($options);
231
232        return $handler->send($request, $options);
233    }
234
235    // â”€â”€â”€ Verb Convenience Methods â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
236
237    /**
238     * Send a GET request.
239     *
240     * @param string $uri Request URI (absolute, or relative to `base_uri`)
241     * @param array<string, mixed> $params Query parameters to append
242     * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...)
243     */
244    public function get(string $uri, array $params = [], array $options = []): ResponseInterface
245    {
246        if (!empty($params)) {
247            $separator = str_contains($uri, '?') ? '&' : '?';
248            $uri .= $separator . http_build_query($params);
249        }
250
251        $request = $this->factory->createRequest('GET', $uri);
252
253        return $this->sendRequest($request, $options);
254    }
255
256    /**
257     * Send a POST request.
258     *
259     * Arrays are JSON-encoded and sent with `Content-Type: application/json`.
260     *
261     * @param string $uri Request URI
262     * @param array<mixed>|string|StreamInterface $body Request body
263     * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...)
264     */
265    public function post(string $uri, array|string|StreamInterface $body = [], array $options = []): ResponseInterface
266    {
267        return $this->sendWithBody('POST', $uri, $body, $options);
268    }
269
270    /**
271     * Send a PUT request.
272     *
273     * Arrays are JSON-encoded and sent with `Content-Type: application/json`.
274     *
275     * @param string $uri Request URI
276     * @param array<mixed>|string|StreamInterface $body Request body
277     * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...)
278     */
279    public function put(string $uri, array|string|StreamInterface $body = [], array $options = []): ResponseInterface
280    {
281        return $this->sendWithBody('PUT', $uri, $body, $options);
282    }
283
284    /**
285     * Send a PATCH request.
286     *
287     * Arrays are JSON-encoded and sent with `Content-Type: application/json`.
288     *
289     * @param string $uri Request URI
290     * @param array<mixed>|string|StreamInterface $body Request body
291     * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...)
292     */
293    public function patch(string $uri, array|string|StreamInterface $body = [], array $options = []): ResponseInterface
294    {
295        return $this->sendWithBody('PATCH', $uri, $body, $options);
296    }
297
298    /**
299     * Send a DELETE request.
300     *
301     * Arrays are JSON-encoded and sent with `Content-Type: application/json`.
302     *
303     * @param string $uri Request URI
304     * @param array<mixed>|string|StreamInterface $body Request body
305     * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...)
306     */
307    public function delete(string $uri, array|string|StreamInterface $body = [], array $options = []): ResponseInterface
308    {
309        return $this->sendWithBody('DELETE', $uri, $body, $options);
310    }
311
312    /**
313     * Send a HEAD request.
314     *
315     * @param string $uri Request URI
316     * @param array<string, mixed> $params Query parameters to append
317     * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...)
318     */
319    public function head(string $uri, array $params = [], array $options = []): ResponseInterface
320    {
321        if (!empty($params)) {
322            $separator = str_contains($uri, '?') ? '&' : '?';
323            $uri .= $separator . http_build_query($params);
324        }
325
326        $request = $this->factory->createRequest('HEAD', $uri);
327
328        return $this->sendRequest($request, $options);
329    }
330
331    // â”€â”€â”€ Internals â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
332
333    /**
334     * @param array<mixed>|string|StreamInterface $body
335     * @param array<string, mixed> $options
336     */
337    private function sendWithBody(string $method, string $uri, array|string|StreamInterface $body, array $options): ResponseInterface
338    {
339        $request = $this->factory->createRequest($method, $uri);
340
341        if (is_array($body)) {
342            $encoded = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
343            if ($encoded === false) {
344                throw new RequestException(
345                    'Unable to JSON-encode request body: ' . json_last_error_msg(),
346                    $request
347                );
348            }
349
350            $request = $request
351                ->withHeader('Content-Type', 'application/json')
352                ->withBody(Stream::fromString($encoded));
353        } elseif ($body instanceof StreamInterface) {
354            $request = $request->withBody($body);
355        } else {
356            $request = $request->withBody(Stream::fromString($body));
357        }
358
359        return $this->sendRequest($request, $options);
360    }
361
362    /**
363     * Validate the config array, throwing on unknown keys or invalid values.
364     *
365     * @param array<string, mixed> $config
366     * @throws \InvalidArgumentException
367     */
368    private function validateConfig(array $config): void
369    {
370        $allowed = ['base_uri', 'timeout', 'verify_ssl', 'basic_auth', 'user_agent', 'headers', 'curl_options'];
371
372        foreach ($config as $key => $value) {
373            if (!in_array($key, $allowed, true)) {
374                throw new \InvalidArgumentException("Unknown Client config key: {$key}");
375            }
376        }
377
378        if (array_key_exists('base_uri', $config) && !is_string($config['base_uri']) && !$config['base_uri'] instanceof UriInterface) {
379            throw new \InvalidArgumentException('base_uri must be a string or UriInterface');
380        }
381
382        if (array_key_exists('timeout', $config) && (!is_int($config['timeout']) || $config['timeout'] <= 0)) {
383            throw new \InvalidArgumentException('timeout must be a positive integer');
384        }
385
386        if (array_key_exists('verify_ssl', $config) && !is_bool($config['verify_ssl'])) {
387            throw new \InvalidArgumentException('verify_ssl must be a boolean');
388        }
389
390        if (array_key_exists('basic_auth', $config)) {
391            $basicAuth = $config['basic_auth'];
392            if ($basicAuth !== null
393                && (!is_array($basicAuth)
394                    || count($basicAuth) !== 2
395                    || !is_string($basicAuth[0] ?? null)
396                    || !is_string($basicAuth[1] ?? null))) {
397                throw new \InvalidArgumentException('basic_auth must be an array of [username, password] strings');
398            }
399        }
400
401        if (array_key_exists('user_agent', $config) && !is_string($config['user_agent'])) {
402            throw new \InvalidArgumentException('user_agent must be a string');
403        }
404
405        if (array_key_exists('headers', $config)) {
406            if (!is_array($config['headers'])) {
407                throw new \InvalidArgumentException('headers must be an array');
408            }
409            $this->assertSafeHeaders($config['headers']);
410        }
411
412        if (array_key_exists('curl_options', $config) && !is_array($config['curl_options'])) {
413            throw new \InvalidArgumentException('curl_options must be an array');
414        }
415
416        // Fail fast on config-level curl options that conflict with the
417        // handler's own transport handling (single source of truth lives in
418        // CurlHandler so per-request `curl` options are checked identically).
419        if (array_key_exists('curl_options', $config)) {
420            foreach ($config['curl_options'] as $option => $_) {
421                if (isset(CurlHandler::CONFLICTING_CURL_OPTIONS[$option])) {
422                    throw new \InvalidArgumentException(
423                        'curl_options must not override ' . CurlHandler::CONFLICTING_CURL_OPTIONS[$option]
424                    );
425                }
426            }
427        }
428    }
429
430    /**
431     * Validate per-request options, throwing on unknown keys or invalid values.
432     *
433     * @param array<string, mixed> $options
434     * @throws \InvalidArgumentException
435     */
436    private function validateOptions(array $options): void
437    {
438        $allowed = ['sink', 'timeout', 'verify_ssl', 'headers', 'curl', 'user_agent', 'basic_auth', 'query', 'progress', 'stream', 'max_response_size'];
439
440        foreach ($options as $key => $value) {
441            if (!in_array($key, $allowed, true)) {
442                throw new \InvalidArgumentException("Unknown Client request option: {$key}");
443            }
444        }
445
446        if (array_key_exists('stream', $options) && !is_bool($options['stream'])) {
447            throw new \InvalidArgumentException('stream must be a boolean');
448        }
449
450        if (array_key_exists('timeout', $options) && (!is_int($options['timeout']) || $options['timeout'] <= 0)) {
451            throw new \InvalidArgumentException('timeout must be a positive integer');
452        }
453
454        if (array_key_exists('verify_ssl', $options) && !is_bool($options['verify_ssl'])) {
455            throw new \InvalidArgumentException('verify_ssl must be a boolean');
456        }
457
458        if (array_key_exists('headers', $options)) {
459            if (!is_array($options['headers'])) {
460                throw new \InvalidArgumentException('headers must be an array');
461            }
462            $this->assertSafeHeaders($options['headers']);
463        }
464
465        if (array_key_exists('curl', $options) && !is_array($options['curl'])) {
466            throw new \InvalidArgumentException('curl must be an array');
467        }
468
469        if (array_key_exists('max_response_size', $options) && (!is_int($options['max_response_size']) || $options['max_response_size'] <= 0)) {
470            throw new \InvalidArgumentException('max_response_size must be a positive integer');
471        }
472
473        if (array_key_exists('basic_auth', $options) && $options['basic_auth'] !== null) {
474            $basicAuth = $options['basic_auth'];
475            if (!is_array($basicAuth)
476                || count($basicAuth) !== 2
477                || !is_string($basicAuth[0] ?? null)
478                || !is_string($basicAuth[1] ?? null)) {
479                throw new \InvalidArgumentException('basic_auth must be an array of [username, password] strings');
480            }
481        }
482    }
483
484    /**
485     * Reject header names/values containing CR/LF, which would otherwise be
486     * concatenated verbatim into header lines and enable header injection.
487     *
488     * The PSR-7 request-header path is already sanitized by AbstractMessage;
489     * this closes the same hole for the `headers` config/option array, which
490     * bypasses that path.
491     *
492     * @param array<string, mixed> $headers
493     * @throws \InvalidArgumentException
494     */
495    private function assertSafeHeaders(array $headers): void
496    {
497        foreach ($headers as $name => $value) {
498            if (is_string($name) && preg_match('/[\r\n]/', $name)) {
499                throw new \InvalidArgumentException('Header name must not contain CR/LF');
500            }
501            if (is_string($value) && preg_match('/[\r\n]/', $value)) {
502                throw new \InvalidArgumentException("Header '{$name}' value must not contain CR/LF");
503            }
504        }
505    }
506}