Lines 92.48% 123 / 133
Methods 57.14% 4 / 7
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 send 93.10% 81 / 87 0.00% 0 / 1 20.13
 createException 92.30% 12 / 13 0.00% 0 / 1 2.00
 validateOptions 100.00% 3 / 3 100.00% 1 / 1 3
 assertNoConflictingCurlOptions 100.00% 6 / 6 100.00% 1 / 1 3
 [Lucent\Http\Client\Handler\Concerns\HandlesResponseBodies] prepareSink 75.00% 9 / 12 0.00% 0 / 1 6.56
 [Lucent\Http\Client\Handler\Concerns\HandlesResponseBodies] parseStatusLine 100.00% 5 / 5 100.00% 1 / 1 3
 [Lucent\Http\Client\Handler\Concerns\HandlesResponseBodies] buildResponse 100.00% 7 / 7 100.00% 1 / 1 2
24final class CurlHandler implements HandlerInterface
25{
26    use HandlesResponseBodies;
27
28    /** @var string Log channel used by the client */
29    private const LOG_CHANNEL = 'lucent.http';
30
31    /**
32     * cURL options that conflict with the client's own handling and must not
33     * be set via `curl_options` / the `curl` request option.
34     *
35     * @var array<int, string>
36     */
37    public const CONFLICTING_CURL_OPTIONS = [
38        CURLOPT_URL => 'CURLOPT_URL',
39        CURLOPT_CUSTOMREQUEST => 'CURLOPT_CUSTOMREQUEST',
40        CURLOPT_RETURNTRANSFER => 'CURLOPT_RETURNTRANSFER',
41        CURLOPT_FILE => 'CURLOPT_FILE',
42        CURLOPT_WRITEFUNCTION => 'CURLOPT_WRITEFUNCTION',
43        CURLOPT_HEADERFUNCTION => 'CURLOPT_HEADERFUNCTION',
44        CURLOPT_POSTFIELDS => 'CURLOPT_POSTFIELDS',
45        CURLOPT_READFUNCTION => 'CURLOPT_READFUNCTION',
46        CURLOPT_INFILESIZE => 'CURLOPT_INFILESIZE',
47        CURLOPT_HTTPHEADER => 'CURLOPT_HTTPHEADER',
48        CURLOPT_USERPWD => 'CURLOPT_USERPWD',
49        CURLOPT_USERAGENT => 'CURLOPT_USERAGENT',
50        CURLOPT_TIMEOUT => 'CURLOPT_TIMEOUT',
51        CURLOPT_SSL_VERIFYPEER => 'CURLOPT_SSL_VERIFYPEER',
52        CURLOPT_SSL_VERIFYHOST => 'CURLOPT_SSL_VERIFYHOST',
53        CURLOPT_NOPROGRESS => 'CURLOPT_NOPROGRESS',
54        CURLOPT_XFERINFOFUNCTION => 'CURLOPT_XFERINFOFUNCTION',
55        CURLOPT_PROGRESSFUNCTION => 'CURLOPT_PROGRESSFUNCTION',
56    ];
57
58    public function send(RequestInterface $request, array $options): ResponseInterface
59    {
60        $url = (string) $request->getUri();
61        $method = $request->getMethod();
62
63        Log::channel(self::LOG_CHANNEL)->info("Starting {$method} request to {$url}");
64
65        $ch = curl_init();
66        if ($ch === false) {
67            throw new RequestException('Unable to initialize cURL', $request);
68        }
69
70        $curl = [
71            CURLOPT_URL => $url,
72            CURLOPT_CUSTOMREQUEST => $method,
73            CURLOPT_FOLLOWLOCATION => true,
74            CURLOPT_MAXREDIRS => 10,
75            CURLOPT_TIMEOUT => $options['timeout'] ?? 30,
76            CURLOPT_USERAGENT => $options['user_agent'] ?? Client::defaultUserAgent(),
77            CURLOPT_SSL_VERIFYPEER => $options['verify_ssl'] ?? true,
78            CURLOPT_SSL_VERIFYHOST => ($options['verify_ssl'] ?? true) ? 2 : 0,
79        ];
80
81        // Headers: defaults + per-request already merged; request headers win.
82        $headers = $options['headers'] ?? [];
83        foreach ($request->getHeaders() as $name => $values) {
84            $headers[$name] = implode(', ', $values);
85        }
86
87        $headerLines = [];
88        foreach ($headers as $name => $value) {
89            $headerLines[] = "{$name}: {$value}";
90        }
91        if (!empty($headerLines)) {
92            $curl[CURLOPT_HTTPHEADER] = $headerLines;
93        }
94
95        // Basic auth.
96        $basicAuth = $options['basic_auth'] ?? null;
97        if ($basicAuth !== null) {
98            $curl[CURLOPT_USERPWD] = $basicAuth[0] . ':' . $basicAuth[1];
99        }
100
101        // Request body. All bodies are streamed from the StreamInterface via
102        // CURLOPT_READFUNCTION — method-agnostic and never fully buffered.
103        // The read function returns '' at EOF (which libcurl treats as end of
104        // transfer). CURLOPT_INFILESIZE is set only when the size is known so
105        // libcurl sends Content-Length; when the stream size is unknown (e.g.
106        // IteratorStream) it falls back to Transfer-Encoding: chunked.
107        $body = $request->getBody();
108        $bodySize = $body->getSize();
109
110        if ($bodySize === null || $bodySize > 0) {
111            $curl[CURLOPT_UPLOAD] = true;
112            if ($bodySize !== null) {
113                $curl[CURLOPT_INFILESIZE] = $bodySize;
114            }
115            $curl[CURLOPT_READFUNCTION] = function ($ch, $fd, int $length) use ($body): string {
116                return $body->read($length);
117            };
118        }
119
120        // Sink: always write the body via WRITEFUNCTION into a stream. The
121        // default is a php://temp stream (seekable, memory-efficient); a
122        // configured sink (path/resource/stream) receives the body instead.
123        // CURLOPT_RETURNTRANSFER is never used — it conflicts with streaming.
124        $sink = $this->prepareSink($options['sink'] ?? null);
125
126        // Optional response-size cap (bytes). When exceeded, abort the
127        // transfer to prevent unbounded memory/disk exhaustion (zip-bomb /
128        // endless-body DoS). CURLOPT_MAXFILESIZE only limits the declared
129        // Content-Length, so we enforce the cap in the write callback too.
130        $maxResponseSize = $options['max_response_size'] ?? null;
131        $received = 0;
132        $curl[CURLOPT_WRITEFUNCTION] = function ($ch, string $data) use ($sink, &$received, $maxResponseSize): int {
133            if ($maxResponseSize !== null) {
134                $received += strlen($data);
135                if ($received > $maxResponseSize) {
136                    return 0; // signal abort to libcurl
137                }
138            }
139            return $sink->write($data);
140        };
141
142        // Custom curl options merged over defaults (validated above).
143        foreach ($options['curl'] ?? [] as $option => $value) {
144            $curl[$option] = $value;
145        }
146
147        // Progress callback (modern XFERINFOFUNCTION API). The callback
148        // receives ($downloaded, $total, $uploaded, $uploadTotal) —
149        // download-first to match the common progress-bar use case, with
150        // upload values appended for callers that need them. PHP ignores
151        // extra args, so 2-arg callbacks keep working unchanged.
152        if (isset($options['progress'])) {
153            $curl[CURLOPT_NOPROGRESS] = false;
154            $curl[CURLOPT_XFERINFOFUNCTION] = function ($ch, $dlTotal, $dlNow, $ulTotal, $ulNow) use ($options): int {
155                ($options['progress'])($dlNow, $dlTotal, $ulNow, $ulTotal);
156                return 0;
157            };
158        }
159
160        curl_setopt_array($ch, $curl);
161
162        // Capture response headers + the status line.
163        $responseHeaders = [];
164        $statusLine = '';
165        curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $header) use (&$responseHeaders, &$statusLine) {
166            $length = strlen($header);
167            $trimmed = trim($header);
168            if ($trimmed === '') {
169                return $length;
170            }
171
172            if (str_starts_with($trimmed, 'HTTP/')) {
173                $statusLine = $trimmed;
174                return $length;
175            }
176
177            $colon = strpos($trimmed, ':');
178            if ($colon === false) {
179                return $length;
180            }
181
182            $name = trim(substr($trimmed, 0, $colon));
183            $value = trim(substr($trimmed, $colon + 1));
184            $responseHeaders[$name][] = $value;
185
186            return $length;
187        });
188
189        $result = curl_exec($ch);
190
191        if ($result === false) {
192            $errno = curl_errno($ch);
193            $error = curl_error($ch);
194            unset($ch);
195
196            Log::channel(self::LOG_CHANNEL)->error("cURL Error ({$errno}): {$error}");
197
198            throw $this->createException($errno, $error, $request);
199        }
200
201        $statusCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
202        unset($ch);
203
204        // Rewind the sink so the response body is readable from the start.
205        if ($sink->isSeekable()) {
206            $sink->rewind();
207        }
208
209        [$version, $status, $reason] = $this->parseStatusLine($statusLine !== '' ? $statusLine : "HTTP/1.1 {$statusCode}");
210
211        Log::channel(self::LOG_CHANNEL)->debug("Completed {$method} request to {$url} with status {$statusCode}");
212
213        return $this->buildResponse($version, $status, $reason, $responseHeaders, $sink);
214    }
215
216    /**
217     * Map a cURL error to a PSR-18 exception.
218     *
219     * Transport-level failures (DNS, proxy, connect, timeout) map to
220     * {@see NetworkException}; everything else to {@see RequestException}.
221     */
222    private function createException(int $errno, string $error, RequestInterface $request): \Psr\Http\Client\ClientExceptionInterface
223    {
224        $networkErrors = [
225            CURLE_COULDNT_RESOLVE_HOST,
226            CURLE_COULDNT_RESOLVE_PROXY,
227            CURLE_COULDNT_CONNECT,
228            CURLE_OPERATION_TIMEDOUT,
229            CURLE_SSL_CONNECT_ERROR,
230            CURLE_RECV_ERROR,
231            CURLE_SEND_ERROR,
232        ];
233
234        $message = "cURL error {$errno}: {$error}";
235
236        if (in_array($errno, $networkErrors, true)) {
237            return new NetworkException($message, $request);
238        }
239
240        return new RequestException($message, $request);
241    }
242
243    /**
244     * Validate handler-specific options.
245     *
246     * @param array<string, mixed> $options Merged per-request options
247     * @throws \InvalidArgumentException
248     */
249    public function validateOptions(array $options): void
250    {
251        if (array_key_exists('progress', $options) && !is_callable($options['progress'])) {
252            throw new \InvalidArgumentException('progress must be a callable');
253        }
254
255        $this->assertNoConflictingCurlOptions($options['curl'] ?? []);
256    }
257
258    /**
259     * @param array<int, mixed> $curlOptions
260     */
261    private function assertNoConflictingCurlOptions(array $curlOptions): void
262    {
263        foreach (array_keys($curlOptions) as $option) {
264            if (isset(self::CONFLICTING_CURL_OPTIONS[$option])) {
265                throw new \InvalidArgumentException(
266                    'curl must not override ' . self::CONFLICTING_CURL_OPTIONS[$option]
267                    . ' — it is managed by the client transport'
268                );
269            }
270        }
271    }
272}

From Lucent\Http\Client\Handler\Concerns\HandlesResponseBodies

18trait HandlesResponseBodies
19{
20    /**
21     * Prepare the response body sink.
22     *
23     * Defaults to a seekable php://temp stream. A configured sink (string
24     * path, resource, or StreamInterface) receives the body instead.
25     *
26     * @param string|resource|StreamInterface|null $sink
27     */
28    private function prepareSink(mixed $sink): StreamInterface
29    {
30        if ($sink === null) {
31            return Stream::fromResource(fopen('php://temp', 'w+'));
32        }
33
34        if ($sink instanceof StreamInterface) {
35            return $sink;
36        }
37
38        if (is_resource($sink)) {
39            return Stream::fromResource($sink);
40        }
41
42        if (is_string($sink)) {
43            $resource = fopen($sink, 'w+');
44            if ($resource === false) {
45                throw new \InvalidArgumentException("Unable to open sink file: {$sink}");
46            }
47            return Stream::fromResource($resource);
48        }
49
50        throw new \InvalidArgumentException('Sink must be a file path, resource, or StreamInterface');
51    }
52
53    /**
54     * Parse an HTTP status line into its components.
55     *
56     * @param string $line e.g. "HTTP/1.1 200 OK"
57     * @return array{0: string, 1: int, 2: string} [version, status, reason]
58     */
59    private function parseStatusLine(string $line): array
60    {
61        $parts = explode(' ', trim($line), 3);
62        $version = isset($parts[0]) ? ltrim($parts[0], 'HTTP/') : '1.1';
63        $status = isset($parts[1]) ? (int) $parts[1] : 200;
64        $reason = $parts[2] ?? '';
65
66        return [$version, $status, $reason];
67    }
68
69    /**
70     * Assemble a complete PSR-7 response.
71     *
72     * Sets protocol version, status, reason phrase, headers, and body — the
73     * same shape regardless of which transport produced it.
74     *
75     * @param array<string, string[]> $headers Headers as [name => values]
76     */
77    private function buildResponse(
78        string $version,
79        int $statusCode,
80        string $reasonPhrase,
81        array $headers,
82        StreamInterface $body
83    ): ResponseInterface {
84        $response = new Response();
85        $response = $response
86            ->withProtocolVersion($version)
87            ->withStatus($statusCode, $reasonPhrase);
88
89        foreach ($headers as $name => $values) {
90            $response = $response->withAddedHeader($name, $values);
91        }
92
93        return $response->withBody($body);
94    }
95}