Lines 89.84% 115 / 128
Methods 55.55% 5 / 9
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 send 100.00% 25 / 25 100.00% 1 / 1 4
 validateOptions 100.00% 8 / 8 100.00% 1 / 1 3
 buildContext 97.14% 34 / 35 0.00% 0 / 1 11
 parseHeaders 100.00% 7 / 7 100.00% 1 / 1 4
 normalizeHeaders 87.50% 14 / 16 0.00% 0 / 1 6.07
 drain 46.15% 6 / 13 0.00% 0 / 1 11.62
 [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
28final class StreamHandler implements HandlerInterface
29{
30    use HandlesResponseBodies;
31
32    /** @var string Log channel used by the client */
33    private const LOG_CHANNEL = 'lucent.http';
34
35    public function send(RequestInterface $request, array $options): ResponseInterface
36    {
37        $url = (string) $request->getUri();
38        $method = $request->getMethod();
39
40        Log::channel(self::LOG_CHANNEL)->info("Starting {$method} request to {$url}");
41
42        // Clear any stale headers from a previous/failed request so we never
43        // read them as this response's headers.
44        http_clear_last_response_headers();
45
46        $context = $this->buildContext($request, $options);
47
48        $resource = @fopen($url, 'r', false, $context);
49
50        if ($resource === false) {
51            $error = error_get_last()['message'] ?? 'Unknown stream error';
52            Log::channel(self::LOG_CHANNEL)->error("Stream Error: {$error}");
53            throw new NetworkException("Unable to open stream: {$error}", $request);
54        }
55
56        // Idle-read timeout on the socket.
57        $timeout = (int) ($options['timeout'] ?? 30);
58        stream_set_timeout($resource, $timeout);
59
60        // Read the response headers. http_get_last_response_headers() was
61        // added in PHP 8.4 (the framework's minimum) — the magic
62        // $http_response_header variable is deprecated in function scope.
63        $rawHeaders = http_get_last_response_headers();
64
65        [$version, $status, $reason] = $this->parseHeaders($rawHeaders ?? []);
66
67        $streaming = !empty($options['stream']);
68
69        if ($streaming) {
70            // Live body: the caller reads incrementally; do NOT close.
71            $body = Stream::fromResource($resource);
72        } else {
73            $sink = $this->prepareSink($options['sink'] ?? null);
74            $this->drain($resource, $sink, $options['max_response_size'] ?? null, $request);
75            fclose($resource);
76            if ($sink->isSeekable()) {
77                $sink->rewind();
78            }
79            $body = $sink;
80        }
81
82        Log::channel(self::LOG_CHANNEL)->debug("Completed {$method} request to {$url} with status {$status}");
83
84        return $this->buildResponse($version, $status, $reason, $this->normalizeHeaders($rawHeaders ?? []), $body);
85    }
86
87    /**
88     * Validate handler-specific options.
89     *
90     * The stream handler cannot honor cURL options or a progress callback.
91     *
92     * @param array<string, mixed> $options Merged per-request options
93     * @throws \InvalidArgumentException
94     */
95    public function validateOptions(array $options): void
96    {
97        if (!empty($options['curl'])) {
98            throw new \InvalidArgumentException(
99                'Passing the "curl" request option to the stream handler is not supported because the stream handler ignores cURL options.'
100            );
101        }
102
103        if (array_key_exists('progress', $options)) {
104            throw new \InvalidArgumentException(
105                'Passing the "progress" request option to the stream handler is not supported because the stream handler has no progress callback.'
106            );
107        }
108    }
109
110    /**
111     * @param array<string, mixed> $options
112     * @return resource
113     */
114    private function buildContext(RequestInterface $request, array $options)
115    {
116        // Headers: defaults + per-request already merged; request headers win.
117        $headers = $options['headers'] ?? [];
118        foreach ($request->getHeaders() as $name => $values) {
119            $headers[$name] = implode(', ', $values);
120        }
121
122        $headerLines = [];
123        foreach ($headers as $name => $value) {
124            $headerLines[] = "{$name}: {$value}";
125        }
126
127        // Basic auth → Authorization header.
128        $basicAuth = $options['basic_auth'] ?? null;
129        if ($basicAuth !== null) {
130            $headerLines[] = 'Authorization: Basic ' . base64_encode($basicAuth[0] . ':' . $basicAuth[1]);
131        }
132
133        // The PHP stream wrapper forwards the configured `header` lines on
134        // redirects without host-based stripping, so with basic_auth set it
135        // would replay the credentials to a cross-host redirect target.
136        // Disable redirect-following in that case to avoid leaking them.
137        $followLocation = $basicAuth === null ? 1 : 0;
138
139        $context = [
140            'http' => [
141                'method' => $request->getMethod(),
142                'protocol_version' => $request->getProtocolVersion(),
143                'ignore_errors' => true,
144                'follow_location' => $followLocation,
145                'max_redirects' => $basicAuth === null ? 10 : 0,
146                'timeout' => (float) ($options['timeout'] ?? 30),
147                'user_agent' => $options['user_agent'] ?? Client::defaultUserAgent(),
148                'header' => $headerLines,
149            ],
150        ];
151
152        // Request body → http context 'content'. When the size is known,
153        // send Content-Length so the server knows when the body is complete.
154        $body = $request->getBody();
155        $bodySize = $body->getSize();
156        if ($bodySize === null || $bodySize > 0) {
157            $context['http']['content'] = (string) $body;
158            if ($bodySize !== null && !$request->hasHeader('Content-Length')) {
159                $headerLines[] = 'Content-Length: ' . $bodySize;
160                $context['http']['header'] = $headerLines;
161            }
162        }
163
164        // SSL verification.
165        if (empty($options['verify_ssl'])) {
166            $context['ssl'] = [
167                'verify_peer' => false,
168                'verify_peer_name' => false,
169            ];
170        }
171
172        return stream_context_create($context);
173    }
174
175    /**
176     * Parse the FINAL status line from the raw headers.
177     *
178     * With follow_location enabled or 1xx interim responses, the raw header
179     * list contains multiple status blocks — the last HTTP/ line belongs to
180     * the final response.
181     *
182     * @param list<string> $rawHeaders Raw header lines (incl. status lines)
183     * @return array{0: string, 1: int, 2: string} [version, status, reason]
184     */
185    private function parseHeaders(array $rawHeaders): array
186    {
187        $lastStatusLine = null;
188        foreach ($rawHeaders as $line) {
189            if (str_starts_with(trim($line), 'HTTP/')) {
190                $lastStatusLine = $line;
191            }
192        }
193
194        return $lastStatusLine !== null
195            ? $this->parseStatusLine($lastStatusLine)
196            : ['1.1', 200, ''];
197    }
198
199    /**
200     * Normalize headers from the FINAL response block only.
201     *
202     * Only header lines after the last HTTP/ status line belong to the final
203     * response; earlier blocks are redirects or 1xx interim responses.
204     *
205     * @param list<string> $rawHeaders Raw header lines (incl. status lines)
206     * @return array<string, string[]> Headers as [name => values]
207     */
208    private function normalizeHeaders(array $rawHeaders): array
209    {
210        // Find the offset of the last status line.
211        $lastStatusOffset = -1;
212        foreach ($rawHeaders as $i => $line) {
213            if (str_starts_with(trim($line), 'HTTP/')) {
214                $lastStatusOffset = $i;
215            }
216        }
217
218        $headers = [];
219        foreach (array_slice($rawHeaders, $lastStatusOffset + 1) as $line) {
220            $trimmed = trim($line);
221            if ($trimmed === '') {
222                continue;
223            }
224
225            $colon = strpos($trimmed, ':');
226            if ($colon === false) {
227                continue;
228            }
229
230            $name = trim(substr($trimmed, 0, $colon));
231            $value = trim(substr($trimmed, $colon + 1));
232            $headers[$name][] = $value;
233        }
234
235        return $headers;
236    }
237
238    private function drain($resource, StreamInterface $sink, ?int $maxResponseSize, RequestInterface $request): void
239    {
240        $received = 0;
241        while (!feof($resource)) {
242            $chunk = fread($resource, 8192);
243            if ($chunk === false || $chunk === '') {
244                break;
245            }
246            if ($maxResponseSize !== null) {
247                $received += strlen($chunk);
248                if ($received > $maxResponseSize) {
249                    throw new RequestException(
250                        "Response exceeded max_response_size of {$maxResponseSize} bytes",
251                        $request
252                    );
253                }
254            }
255            $sink->write($chunk);
256        }
257    }
258}

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}