Skip to content

[HttpClient] Add a mock client #30553

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/Symfony/Component/HttpClient/MockClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\HttpClient;

use Symfony\Component\HttpClient\Chunk\DataChunk;
use Symfony\Component\HttpClient\Chunk\ErrorChunk;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Chunk\LastChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;

/**
* Provides a way to tests the HttpClient without making actual HTTP requests.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
class MockClient implements HttpClientInterface
{
/**
* Predefined responses. Throw a TransportExceptionInterface when none provided.
*
* @var ResponseInterface[]
*/
private $responses = [];

/**
* MockClient constructor.
*
* @param iterable|ResponseInterface[] $responses
*/
public function __construct(iterable $responses = [])
{
foreach ($responses as $response) {
if (!$response instanceof ResponseInterface) {
throw new \TypeError(sprintf('Each predefined response must an instance of %s, %s given.', ResponseInterface::class, \is_object($response) ? \get_class($response) : \gettype($response)));
}

$this->responses[] = $response;
}
}

/**
* {@inheritdoc}
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
if (!\count($this->responses)) {
throw new TransportException('No predefined response to send. Please add one or more using "addResponse" method.');
}

return \array_shift($this->responses);
}

/**
* {@inheritdoc}
*/
public function stream($responses, float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof ResponseInterface) {
$responses = [$responses];
} elseif (!\is_iterable($responses)) {
throw new \TypeError(sprintf('%s() expects parameter 1 to be an iterable of ResponseInterface objects, %s given.', __METHOD__, \is_object($responses) ? \get_class($responses) : \gettype($responses)));
}

return new ResponseStream($this->streamResponses($responses));
}

/**
* @return $this
*/
public function addResponse(ResponseInterface $response)
{
$this->responses[] = $response;

return $this;
}

/**
* Clears all predefined responses.
*/
public function clear(): void
{
$this->responses = [];
}

private function streamResponses(iterable $responses): \Generator
{
foreach ($responses as $response) {
try {
$response->getHeaders(true);

yield $response => new FirstChunk();
yield $response => new DataChunk(0, $content = $response->getContent(true));
yield $response => new LastChunk(\strlen($content));
} catch (TransportExceptionInterface $e) {
$didThrow = false;

yield $response => new ErrorChunk($didThrow, 0, $e);
}
}
}
}
107 changes: 107 additions & 0 deletions src/Symfony/Component/HttpClient/Response/MockResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\Contracts\HttpClient\ResponseInterface;

/**
* A test-friendly response.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
class MockResponse implements ResponseInterface
{
use ResponseTrait;

private const DEFAULT_INFO = [
'raw_headers' => [], // An array modelled after the special $http_response_header variable
'redirect_count' => 0, // The number of redirects followed while executing the request
'redirect_url' => null, // The resolved location of redirect responses, null otherwise
'start_time' => 0.0, // The time when the request was sent or 0.0 when it's pending
'http_method' => 'GET', // The HTTP verb of the last request
'http_code' => 0, // The last response code or 0 when it is not known yet
'error' => null, // The error message when the transfer was aborted, null otherwise
'data' => null, // The value of the "data" request option, null if not set
'url' => '', // The last effective URL of the request
];
/**
* @var callable|null
*/
private $initializer;

public function __construct(string $content = '', int $code = 200, array $headers = [], array $info = [], ?callable $initializer = null)
{
$default = self::DEFAULT_INFO;
$default['start_time'] = microtime(true);

$this->content = $content;
$this->info = \array_merge($default, $info);
$this->info['http_code'] = $code;
$this->initializer = $initializer;
$this->headers = $headers;
}

/**
* {@inheritdoc}
*/
public function getInfo(string $type = null)
{
if ($type) {
return $this->info[$type] ?? null;
}

return $this->info;
}

public function getContent(bool $throw = true): string
{
if ($this->initializer) {
($this->initializer)($this);
$this->initializer = null;
}

if ($throw) {
$this->checkStatusCode();
}

return $this->content;
}

/**
* {@inheritdoc}
*/
protected function close(): void
{
}

/**
* {@inheritdoc}
*/
protected static function schedule(self $response, array &$runningResponses): void
{
}

/**
* {@inheritdoc}
*/
protected static function perform(\stdClass $multi, array &$responses): void
{
}

/**
* {@inheritdoc}
*/
protected static function select(\stdClass $multi, float $timeout): int
{
return 42;
}
}
78 changes: 78 additions & 0 deletions src/Symfony/Component/HttpClient/Tests/MockClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\HttpClient\Tests;

use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\MockClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

class MockClientTest extends TestCase
{
public function testStream()
{
$response = new MockResponse('{"foo": "bar"}');

$client = new MockClient();
$chunks = '';

foreach ($client->stream($response) as $chunk) {
$this->assertInstanceOf(ChunkInterface::class, $chunk);

$chunks .= $chunk->getContent();
}

$this->assertSame('{"foo": "bar"}', $chunks);
}

public function testStreamWithUnhappyResponse()
{
$client = new MockClient();
$response = $this->createMock(ResponseInterface::class);
$e = new TransportException('Something is broken :(');

$response->method('getHeaders')->willThrowException($e);

$this->expectException(TransportException::class);
$this->expectExceptionMessage($e->getMessage());

$client->stream($response)->valid();
}

public function testRequest()
{
/** @var ResponseInterface $response */
$response = new MockResponse();
$client = new MockClient();
$client->addResponse($response);

$this->assertSame($response, $client->request('GET', '/whatever?q=foo', ['base_uri' => 'http://example.org']));
}

public function testRequestWithoutResponse()
{
$this->expectException(TransportException::class);
$this->expectExceptionMessage('No predefined response to send. Please add one or more using "addResponse" method.');

(new MockClient())->request('GET', '/whatever?q=foo', ['base_uri' => 'http://example.org']);
}

public function testConstructWithInvalidType()
{
$this->expectException('TypeError');
$this->expectExceptionMessage('Each predefined response must an instance of Symfony\Contracts\HttpClient\ResponseInterface, stdClass given.');

new MockClient([new \stdClass()]);
}
}