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 | ||
| 28 | final 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
| 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 | } |