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 | ||
| 24 | final 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
| 18 | trait 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 | } |