* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\HttpClient\Response; use Symfony\Component\HttpClient\Chunk\ErrorChunk; use Symfony\Component\HttpClient\Chunk\FirstChunk; use Symfony\Component\HttpClient\Exception\InvalidArgumentException; use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Component\HttpClient\Internal\ClientState; use Symfony\Contracts\HttpClient\ResponseInterface; /** * A test-friendly response. * * @author Nicolas Grekas */ class MockResponse implements ResponseInterface, StreamableInterface { use CommonResponseTrait; use TransportResponseTrait; private string|iterable|null $body; private array $requestOptions = []; private string $requestUrl; private string $requestMethod; private static ClientState $mainMulti; private static int $idSequence = 0; /** * @param string|iterable $body The response body as a string or an iterable of strings, * yielding an empty string simulates an idle timeout, * throwing or yielding an exception yields an ErrorChunk * * @see ResponseInterface::getInfo() for possible info, e.g. "response_headers" */ public function __construct(string|iterable $body = '', array $info = []) { $this->body = $body; $this->info = $info + ['http_code' => 200] + $this->info; if (!isset($info['response_headers'])) { return; } $responseHeaders = []; foreach ($info['response_headers'] as $k => $v) { foreach ((array) $v as $v) { $responseHeaders[] = (\is_string($k) ? $k.': ' : '').$v; } } $this->info['response_headers'] = []; self::addResponseHeaders($responseHeaders, $this->info, $this->headers); } public static function fromFile(string $path, array $info = []): static { if (!is_file($path)) { throw new \InvalidArgumentException(sprintf('File not found: "%s".', $path)); } return new static(file_get_contents($path), $info); } /** * Returns the options used when doing the request. */ public function getRequestOptions(): array { return $this->requestOptions; } /** * Returns the URL used when doing the request. */ public function getRequestUrl(): string { return $this->requestUrl; } /** * Returns the method used when doing the request. */ public function getRequestMethod(): string { return $this->requestMethod; } public function getInfo(?string $type = null): mixed { return null !== $type ? $this->info[$type] ?? null : $this->info; } public function cancel(): void { $this->info['canceled'] = true; $this->info['error'] = 'Response has been canceled.'; try { $this->body = null; } catch (TransportException $e) { // ignore errors when canceling } $onProgress = $this->requestOptions['on_progress'] ?? static function () {}; $dlSize = isset($this->headers['content-encoding']) || 'HEAD' === ($this->info['http_method'] ?? null) || \in_array($this->info['http_code'], [204, 304], true) ? 0 : (int) ($this->headers['content-length'][0] ?? 0); $onProgress($this->offset, $dlSize, $this->info); } public function __destruct() { $this->doDestruct(); } protected function close(): void { $this->inflate = null; $this->body = []; } /** * @internal */ public static function fromRequest(string $method, string $url, array $options, ResponseInterface $mock): self { $response = new self([]); $response->requestOptions = $options; $response->id = ++self::$idSequence; $response->shouldBuffer = $options['buffer'] ?? true; $response->initializer = static fn (self $response) => \is_array($response->body[0] ?? null); $response->info['redirect_count'] = 0; $response->info['redirect_url'] = null; $response->info['start_time'] = microtime(true); $response->info['http_method'] = $method; $response->info['http_code'] = 0; $response->info['user_data'] = $options['user_data'] ?? null; $response->info['max_duration'] = $options['max_duration'] ?? null; $response->info['url'] = $url; $response->info['original_url'] = $url; if ($mock instanceof self) { $mock->requestOptions = $response->requestOptions; $mock->requestMethod = $method; $mock->requestUrl = $url; } self::writeRequest($response, $options, $mock); $response->body[] = [$options, $mock]; return $response; } protected static function schedule(self $response, array &$runningResponses): void { if (!isset($response->id)) { throw new InvalidArgumentException('MockResponse instances must be issued by MockHttpClient before processing.'); } $multi = self::$mainMulti ??= new ClientState(); if (!isset($runningResponses[0])) { $runningResponses[0] = [$multi, []]; } $runningResponses[0][1][$response->id] = $response; } protected static function perform(ClientState $multi, array &$responses): void { foreach ($responses as $response) { $id = $response->id; if (null === $response->body) { // Canceled response $response->body = []; } elseif ([] === $response->body) { // Error chunk $multi->handlesActivity[$id][] = null; $multi->handlesActivity[$id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null; } elseif (null === $chunk = array_shift($response->body)) { // Last chunk $multi->handlesActivity[$id][] = null; $multi->handlesActivity[$id][] = array_shift($response->body); } elseif (\is_array($chunk)) { // First chunk try { $offset = 0; $chunk[1]->getStatusCode(); $chunk[1]->getHeaders(false); self::readResponse($response, $chunk[0], $chunk[1], $offset); $multi->handlesActivity[$id][] = new FirstChunk(); } catch (\Throwable $e) { $multi->handlesActivity[$id][] = null; $multi->handlesActivity[$id][] = $e; } } elseif ($chunk instanceof \Throwable) { $multi->handlesActivity[$id][] = null; $multi->handlesActivity[$id][] = $chunk; } else { // Data or timeout chunk $multi->handlesActivity[$id][] = $chunk; } } } protected static function select(ClientState $multi, float $timeout): int { return 42; } /** * Simulates sending the request. */ private static function writeRequest(self $response, array $options, ResponseInterface $mock): void { $onProgress = $options['on_progress'] ?? static function () {}; $response->info += $mock->getInfo() ?: []; if (null !== $mock->getInfo('start_time')) { $response->info['start_time'] = $mock->getInfo('start_time'); } // simulate "size_upload" if it is set if (isset($response->info['size_upload'])) { $response->info['size_upload'] = 0.0; } // simulate "total_time" if it is not set if (!isset($response->info['total_time'])) { $response->info['total_time'] = microtime(true) - $response->info['start_time']; } // "notify" DNS resolution $onProgress(0, 0, $response->info); // consume the request body if (\is_resource($body = $options['body'] ?? '')) { $data = stream_get_contents($body); if (isset($response->info['size_upload'])) { $response->info['size_upload'] += \strlen($data); } } elseif ($body instanceof \Closure) { while ('' !== $data = $body(16372)) { if (!\is_string($data)) { throw new TransportException(sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data))); } // "notify" upload progress if (isset($response->info['size_upload'])) { $response->info['size_upload'] += \strlen($data); } $onProgress(0, 0, $response->info); } } } /** * Simulates reading the response. */ private static function readResponse(self $response, array $options, ResponseInterface $mock, int &$offset): void { $onProgress = $options['on_progress'] ?? static function () {}; // populate info related to headers $info = $mock->getInfo() ?: []; $response->info['http_code'] = ($info['http_code'] ?? 0) ?: $mock->getStatusCode() ?: 200; $response->addResponseHeaders($info['response_headers'] ?? [], $response->info, $response->headers); $dlSize = isset($response->headers['content-encoding']) || 'HEAD' === $response->info['http_method'] || \in_array($response->info['http_code'], [204, 304], true) ? 0 : (int) ($response->headers['content-length'][0] ?? 0); $response->info = [ 'start_time' => $response->info['start_time'], 'user_data' => $response->info['user_data'], 'max_duration' => $response->info['max_duration'], 'http_code' => $response->info['http_code'], ] + $info + $response->info; if (null !== $response->info['error']) { throw new TransportException($response->info['error']); } if (!isset($response->info['total_time'])) { $response->info['total_time'] = microtime(true) - $response->info['start_time']; } // "notify" headers arrival $onProgress(0, $dlSize, $response->info); // cast response body to activity list $body = $mock instanceof self ? $mock->body : $mock->getContent(false); if (!\is_string($body)) { try { foreach ($body as $chunk) { if ($chunk instanceof \Throwable) { throw $chunk; } if ('' === $chunk = (string) $chunk) { // simulate an idle timeout $response->body[] = new ErrorChunk($offset, sprintf('Idle timeout reached for "%s".', $response->info['url'])); } else { $response->body[] = $chunk; $offset += \strlen($chunk); // "notify" download progress $onProgress($offset, $dlSize, $response->info); } } } catch (\Throwable $e) { $response->body[] = $e; } } elseif ('' !== $body) { $response->body[] = $body; $offset = \strlen($body); } if (!isset($response->info['total_time'])) { $response->info['total_time'] = microtime(true) - $response->info['start_time']; } // "notify" completion $onProgress($offset, $dlSize, $response->info); if ($dlSize && $offset !== $dlSize) { throw new TransportException(sprintf('Transfer closed with %d bytes remaining to read.', $dlSize - $offset)); } } }