MockResponse.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\HttpClient\Response;
  11. use Symfony\Component\HttpClient\Chunk\ErrorChunk;
  12. use Symfony\Component\HttpClient\Chunk\FirstChunk;
  13. use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
  14. use Symfony\Component\HttpClient\Exception\TransportException;
  15. use Symfony\Component\HttpClient\Internal\ClientState;
  16. use Symfony\Contracts\HttpClient\ResponseInterface;
  17. /**
  18. * A test-friendly response.
  19. *
  20. * @author Nicolas Grekas <p@tchwork.com>
  21. */
  22. class MockResponse implements ResponseInterface, StreamableInterface
  23. {
  24. use CommonResponseTrait;
  25. use TransportResponseTrait;
  26. /**
  27. * @var string|mixed[]
  28. */
  29. private $body;
  30. /**
  31. * @var mixed[]
  32. */
  33. private $requestOptions = [];
  34. /**
  35. * @var string
  36. */
  37. private $requestUrl;
  38. /**
  39. * @var string
  40. */
  41. private $requestMethod;
  42. /**
  43. * @var \Symfony\Component\HttpClient\Internal\ClientState
  44. */
  45. private static $mainMulti;
  46. /**
  47. * @var int
  48. */
  49. private static $idSequence = 0;
  50. /**
  51. * @param string|iterable<string|\Throwable> $body The response body as a string or an iterable of strings,
  52. * yielding an empty string simulates an idle timeout,
  53. * throwing or yielding an exception yields an ErrorChunk
  54. *
  55. * @see ResponseInterface::getInfo() for possible info, e.g. "response_headers"
  56. * @param mixed[] $info
  57. */
  58. public function __construct($body = '', $info = [])
  59. {
  60. $this->body = $body;
  61. $this->info = $info + ['http_code' => 200] + $this->info;
  62. if (!isset($info['response_headers'])) {
  63. return;
  64. }
  65. $responseHeaders = [];
  66. foreach ($info['response_headers'] as $k => $v) {
  67. foreach ((array) $v as $v) {
  68. $responseHeaders[] = (\is_string($k) ? $k.': ' : '').$v;
  69. }
  70. }
  71. $this->info['response_headers'] = [];
  72. self::addResponseHeaders($responseHeaders, $this->info, $this->headers);
  73. }
  74. /**
  75. * Returns the options used when doing the request.
  76. */
  77. public function getRequestOptions()
  78. {
  79. return $this->requestOptions;
  80. }
  81. /**
  82. * Returns the URL used when doing the request.
  83. */
  84. public function getRequestUrl()
  85. {
  86. return $this->requestUrl;
  87. }
  88. /**
  89. * Returns the method used when doing the request.
  90. */
  91. public function getRequestMethod()
  92. {
  93. return $this->requestMethod;
  94. }
  95. /**
  96. * @return mixed
  97. * @param string|null $type
  98. */
  99. public function getInfo($type = null)
  100. {
  101. return null !== $type ? $this->info[$type] ?? null : $this->info;
  102. }
  103. public function cancel()
  104. {
  105. $this->info['canceled'] = true;
  106. $this->info['error'] = 'Response has been canceled.';
  107. try {
  108. unset($this->body);
  109. } catch (TransportException $e) {
  110. // ignore errors when canceling
  111. }
  112. $onProgress = $this->requestOptions['on_progress'] ?? static function () {};
  113. $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);
  114. $onProgress($this->offset, $dlSize, $this->info);
  115. }
  116. public function __destruct()
  117. {
  118. $this->doDestruct();
  119. }
  120. protected function close()
  121. {
  122. $this->inflate = null;
  123. $this->body = [];
  124. }
  125. /**
  126. * @internal
  127. * @param string $method
  128. * @param string $url
  129. * @param mixed[] $options
  130. * @param \Symfony\Contracts\HttpClient\ResponseInterface $mock
  131. */
  132. public static function fromRequest($method, $url, $options, $mock)
  133. {
  134. $response = new self([]);
  135. $response->requestOptions = $options;
  136. $response->id = ++self::$idSequence;
  137. $response->shouldBuffer = $options['buffer'] ?? true;
  138. $response->initializer = static function (self $response) {
  139. return \is_array($response->body[0] ?? null);
  140. };
  141. $response->info['redirect_count'] = 0;
  142. $response->info['redirect_url'] = null;
  143. $response->info['start_time'] = microtime(true);
  144. $response->info['http_method'] = $method;
  145. $response->info['http_code'] = 0;
  146. $response->info['user_data'] = $options['user_data'] ?? null;
  147. $response->info['max_duration'] = $options['max_duration'] ?? null;
  148. $response->info['url'] = $url;
  149. $response->info['original_url'] = $url;
  150. if ($mock instanceof self) {
  151. $mock->requestOptions = $response->requestOptions;
  152. $mock->requestMethod = $method;
  153. $mock->requestUrl = $url;
  154. }
  155. self::writeRequest($response, $options, $mock);
  156. $response->body[] = [$options, $mock];
  157. return $response;
  158. }
  159. /**
  160. * @param $this $response
  161. * @param mixed[] $runningResponses
  162. */
  163. protected static function schedule($response, &$runningResponses)
  164. {
  165. if (!isset($response->id)) {
  166. throw new InvalidArgumentException('MockResponse instances must be issued by MockHttpClient before processing.');
  167. }
  168. $multi = self::$mainMulti = self::$mainMulti ?? new ClientState();
  169. if (!isset($runningResponses[0])) {
  170. $runningResponses[0] = [$multi, []];
  171. }
  172. $runningResponses[0][1][$response->id] = $response;
  173. }
  174. /**
  175. * @param \Symfony\Component\HttpClient\Internal\ClientState $multi
  176. * @param mixed[] $responses
  177. */
  178. protected static function perform($multi, &$responses)
  179. {
  180. foreach ($responses as $response) {
  181. $id = $response->id;
  182. if (!isset($response->body)) {
  183. // Canceled response
  184. $response->body = [];
  185. } elseif ([] === $response->body) {
  186. // Error chunk
  187. $multi->handlesActivity[$id][] = null;
  188. $multi->handlesActivity[$id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
  189. } elseif (null === $chunk = array_shift($response->body)) {
  190. // Last chunk
  191. $multi->handlesActivity[$id][] = null;
  192. $multi->handlesActivity[$id][] = array_shift($response->body);
  193. } elseif (\is_array($chunk)) {
  194. // First chunk
  195. try {
  196. $offset = 0;
  197. $chunk[1]->getStatusCode();
  198. $chunk[1]->getHeaders(false);
  199. self::readResponse($response, $chunk[0], $chunk[1], $offset);
  200. $multi->handlesActivity[$id][] = new FirstChunk();
  201. } catch (\Throwable $e) {
  202. $multi->handlesActivity[$id][] = null;
  203. $multi->handlesActivity[$id][] = $e;
  204. }
  205. } elseif ($chunk instanceof \Throwable) {
  206. $multi->handlesActivity[$id][] = null;
  207. $multi->handlesActivity[$id][] = $chunk;
  208. } else {
  209. // Data or timeout chunk
  210. $multi->handlesActivity[$id][] = $chunk;
  211. }
  212. }
  213. }
  214. /**
  215. * @param \Symfony\Component\HttpClient\Internal\ClientState $multi
  216. * @param float $timeout
  217. */
  218. protected static function select($multi, $timeout)
  219. {
  220. return 42;
  221. }
  222. /**
  223. * Simulates sending the request.
  224. * @param $this $response
  225. * @param mixed[] $options
  226. * @param \Symfony\Contracts\HttpClient\ResponseInterface $mock
  227. */
  228. private static function writeRequest($response, $options, $mock)
  229. {
  230. $onProgress = $options['on_progress'] ?? static function () {};
  231. $response->info += $mock->getInfo() ?: [];
  232. // simulate "size_upload" if it is set
  233. if (isset($response->info['size_upload'])) {
  234. $response->info['size_upload'] = 0.0;
  235. }
  236. // simulate "total_time" if it is not set
  237. if (!isset($response->info['total_time'])) {
  238. $response->info['total_time'] = microtime(true) - $response->info['start_time'];
  239. }
  240. // "notify" DNS resolution
  241. $onProgress(0, 0, $response->info);
  242. // consume the request body
  243. if (\is_resource($body = $options['body'] ?? '')) {
  244. $data = stream_get_contents($body);
  245. if (isset($response->info['size_upload'])) {
  246. $response->info['size_upload'] += \strlen($data);
  247. }
  248. } elseif ($body instanceof \Closure) {
  249. while ('' !== $data = $body(16372)) {
  250. if (!\is_string($data)) {
  251. throw new TransportException(sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data)));
  252. }
  253. // "notify" upload progress
  254. if (isset($response->info['size_upload'])) {
  255. $response->info['size_upload'] += \strlen($data);
  256. }
  257. $onProgress(0, 0, $response->info);
  258. }
  259. }
  260. }
  261. /**
  262. * Simulates reading the response.
  263. * @param $this $response
  264. * @param mixed[] $options
  265. * @param \Symfony\Contracts\HttpClient\ResponseInterface $mock
  266. * @param int $offset
  267. */
  268. private static function readResponse($response, $options, $mock, &$offset)
  269. {
  270. $onProgress = $options['on_progress'] ?? static function () {};
  271. // populate info related to headers
  272. $info = $mock->getInfo() ?: [];
  273. $response->info['http_code'] = ($info['http_code'] ?? 0) ?: $mock->getStatusCode() ?: 200;
  274. $response->addResponseHeaders($info['response_headers'] ?? [], $response->info, $response->headers);
  275. $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);
  276. $response->info = [
  277. 'start_time' => $response->info['start_time'],
  278. 'user_data' => $response->info['user_data'],
  279. 'max_duration' => $response->info['max_duration'],
  280. 'http_code' => $response->info['http_code'],
  281. ] + $info + $response->info;
  282. if (null !== $response->info['error']) {
  283. throw new TransportException($response->info['error']);
  284. }
  285. if (!isset($response->info['total_time'])) {
  286. $response->info['total_time'] = microtime(true) - $response->info['start_time'];
  287. }
  288. // "notify" headers arrival
  289. $onProgress(0, $dlSize, $response->info);
  290. // cast response body to activity list
  291. $body = $mock instanceof self ? $mock->body : $mock->getContent(false);
  292. if (!\is_string($body)) {
  293. try {
  294. foreach ($body as $chunk) {
  295. if ($chunk instanceof \Throwable) {
  296. throw $chunk;
  297. }
  298. if ('' === $chunk = (string) $chunk) {
  299. // simulate an idle timeout
  300. $response->body[] = new ErrorChunk($offset, sprintf('Idle timeout reached for "%s".', $response->info['url']));
  301. } else {
  302. $response->body[] = $chunk;
  303. $offset += \strlen($chunk);
  304. // "notify" download progress
  305. $onProgress($offset, $dlSize, $response->info);
  306. }
  307. }
  308. } catch (\Throwable $e) {
  309. $response->body[] = $e;
  310. }
  311. } elseif ('' !== $body) {
  312. $response->body[] = $body;
  313. $offset = \strlen($body);
  314. }
  315. if (!isset($response->info['total_time'])) {
  316. $response->info['total_time'] = microtime(true) - $response->info['start_time'];
  317. }
  318. // "notify" completion
  319. $onProgress($offset, $dlSize, $response->info);
  320. if ($dlSize && $offset !== $dlSize) {
  321. throw new TransportException(sprintf('Transfer closed with %d bytes remaining to read.', $dlSize - $offset));
  322. }
  323. }
  324. }