Skip to content

(WIP) [HttpClient] Add a Record & Replay callback to the MockHttpClient. #30661

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 3 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
110 changes: 110 additions & 0 deletions src/Symfony/Component/HttpClient/RecordAndReplayCallback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?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\Exception\TransportException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseNamingStrategyInterface;
use Symfony\Contracts\HttpClient\ResponseRecorderInterface;

/**
* Provides a way to record & replay responses.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
class RecordAndReplayCallback
{
const MODE_REPLAY = 'replay';
const MODE_RECORD = 'record';
const MODE_REPLAY_OR_RECORD = 'replay_or_record';

/**
* @var HttpClientInterface
*/
private $client;

/**
* @var ResponseNamingStrategyInterface
*/
private $strategy;

/**
* @var ResponseRecorderInterface
*/
private $recorder;

/**
* @var string
*/
private $mode;

public function __construct(ResponseNamingStrategyInterface $strategy, ResponseRecorderInterface $recorder, string $mode, ?HttpClientInterface $client = null)
{
$this->strategy = $strategy;
$this->recorder = $recorder;
$this->setMode($mode);
$this->client = $client ?? HttpClient::create();
}

public function __invoke(string $method, string $url, array $options): ResponseInterface
{
$response = null;
$name = $this->strategy->name($method, $url, $options);

if (static::MODE_RECORD !== $this->mode) {
$response = $this->recorder->replay($name);
}

if (static::MODE_RECORD === $this->mode || (!$response && $this->mode === static::MODE_REPLAY_OR_RECORD)) {
$response = $this->client->request($method, $url, $options);

$this->recorder->record($name, $response);
}

if (null === $response) {
throw new TransportException("Unable to retrieve the response \"$name\".");
}

return $response;
}

/**
* @return $this
*/
public function setMode(string $mode)
{
$modes = static::getModes();

if (!\in_array($mode, $modes, true)) {
throw new \InvalidArgumentException(sprintf('Invalid provided mode "%s", available choices are: %s', $mode, implode(', ', $modes)));
}

$this->mode = $mode;

return $this;
}

public static function getModes(): array
{
$modes = [];
$ref = new \ReflectionClass(__CLASS__);

foreach ($ref->getConstants() as $constant => $value) {
if (0 === strpos($constant, 'MODE_')) {
$modes[] = $value;
}
}

return $modes;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?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\ResponseRecorder;

use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseRecorderInterface;

/**
* Saves responses in a defined directory.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
class FilesystemResponseRecorder implements ResponseRecorderInterface
{
/**
* @var string
*/
private $directory;

/**
* @var Filesystem
*/
private $fs;

public function __construct(string $directory, ?Filesystem $fs = null)
{
$this->fs = $fs ?? new Filesystem();

if (!$this->fs->exists($directory)) {
$this->fs->mkdir($directory);
}

$this->directory = realpath($directory);
}

public function record(string $name, ResponseInterface $response): void
{
$this->fs->dumpFile($this->getFilename($name), serialize(new MockResponse($response->getContent(), $response->getInfo())));
}

public function replay(string $name): ?ResponseInterface
{
$filename = $this->getFilename($name);

if (!$this->fs->exists($filename)) {
return null;
}

return unserialize(file_get_contents($filename));
}

private function getFilename(string $name): string
{
$sep = \DIRECTORY_SEPARATOR;

return "{$this->directory}{$sep}{$name}.txt";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?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\ResponseRecorder;

use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseRecorderInterface;
use Symfony\Contracts\Service\ResetInterface;

/**
* Saves responses in memory. Responses will be lost at the end of the PHP process.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
class InMemoryRecorder implements ResponseRecorderInterface, ResetInterface
{
/**
* @var ResponseInterface[]
*/
private $responses = [];

public function record(string $name, ResponseInterface $response): void
{
$this->responses[$name] = $response;
}

public function replay(string $name): ?ResponseInterface
{
return $this->responses[$name] ?? null;
}

public function reset()
{
$this->responses = [];
}
}
106 changes: 106 additions & 0 deletions src/Symfony/Component/HttpClient/Tests/RecordAndReplayCallbackTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?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\MockHttpClient;
use Symfony\Component\HttpClient\RecordAndReplayCallback;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseNamingStrategyInterface;
use Symfony\Contracts\HttpClient\ResponseRecorderInterface;

class RecordAndReplayCallbackTest extends TestCase
{
/**
* @var ResponseNamingStrategyInterface
*/
private $strategy;

/**
* @var ResponseRecorderInterface|\PHPUnit\Framework\MockObject\MockObject
*/
private $recorder;

/**
* @var RecordAndReplayCallback
*/
private $responseFactory;

/**
* @var HttpClientInterface|\PHPUnit\Framework\MockObject\MockObject
*/
private $backupClient;

/**
* @var MockHttpClient
*/
private $client;

protected function setUp(): void
{
$this->backupClient = $this->createMock(HttpClientInterface::class);
$this->recorder = $this->createMock(ResponseRecorderInterface::class);
$this->strategy = $this->createMock(ResponseNamingStrategyInterface::class);
$this->strategy->expects($this->any())->method('name')->willReturn('a_unique_name');
$this->responseFactory = new RecordAndReplayCallback($this->strategy, $this->recorder, RecordAndReplayCallback::MODE_REPLAY, $this->backupClient);
$this->client = new MockHttpClient($this->responseFactory);
}

public function testReplayOrRecord()
{
$this->responseFactory->setMode(RecordAndReplayCallback::MODE_REPLAY_OR_RECORD);
$this->recorder->expects($this->once())->method('record');
$this->recorder->expects($this->exactly(2))
->method('replay')
->willReturnOnConsecutiveCalls(null, new MockResponse('I\'m a replayed response.'));
$this->backupClient->expects($this->once())
->method('request')
->willReturn(new MockResponse('I\'m a "live" response.'));

$this->assertSame('I\'m a "live" response.', $this->client->request('GET', 'https://example.org/whatever')->getContent());
$this->assertSame('I\'m a replayed response.', $this->client->request('GET', 'https://example.org/whatever')->getContent());
}

public function testReplay()
{
$this->recorder->expects($this->once())->method('replay')->willReturn(new MockResponse('I\'m a replayed response.'));
$this->backupClient->expects($this->never())->method('request');

$this->assertSame('I\'m a replayed response.', $this->client->request('GET', 'https://example.org/whatever')->getContent());
}

public function testRecord()
{
$this->responseFactory->setMode(RecordAndReplayCallback::MODE_RECORD);
$this->recorder->expects($this->never())->method('replay');
$this->backupClient->expects($this->once())->method('request')->willReturn(new MockResponse('I\'m a "live" response.'));

$this->assertSame('I\'m a "live" response.', $this->client->request('GET', 'https://example.org/whatever')->getContent());
}

public function testReplayThrows()
{
$this->expectException(TransportException::class);
$this->expectExceptionMessage('Unable to retrieve the response "a_unique_name".');

$this->client->request('POST', 'https://example.org/whatever');
}

public function testInvalidMode()
{
$this->expectException('InvalidArgumentException');
$this->expectExceptionMessage('Invalid provided mode "Coucou", available choices are: replay, record, replay_or_record');

$this->responseFactory->setMode('Coucou');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?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\ResponseRecorder;

use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Filesystem\Tests\FilesystemTestCase;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\ResponseRecorder\FilesystemResponseRecorder;
use Symfony\Contracts\HttpClient\ResponseInterface;

class FilesystemResponseRecorderTest extends FilesystemTestCase
{
/**
* @var FilesystemResponseRecorder
*/
private $recorder;

protected function setUp(): void
{
parent::setUp();
$this->recorder = new FilesystemResponseRecorder($this->workspace, $this->filesystem);
}

public function testReplay(): void
{
/** @var ResponseInterface|MockObject $mock */
$mock = $this->createMock(ResponseInterface::class);
$mock->method('getContent')->willReturn('Some nice content');
$mock->method('getInfo')->willReturn(['foo' => 'bar']);

$this->recorder->record('whatever', $mock);

$this->assertFileExists($this->workspace.\DIRECTORY_SEPARATOR.'whatever.txt', 'A file should be created');

$response = $this->recorder->replay('whatever');
$this->assertNotNull($response, 'Response should be retrieved');
$this->assertInstanceOf(MockResponse::class, $response, 'Replay should return a MockResponse');

// MockResponse instances must be issued by MockHttpClient before processing, so content is not yet accessible.
$ref = new \ReflectionClass($response);
$body = $ref->getProperty('body');
$body->setAccessible(true);

$this->assertSame('Some nice content', $body->getValue($response));
$this->assertSame('bar', $response->getInfo('foo'));

$this->assertNull($this->recorder->replay('something_else'), 'Replay should return null here.');
}
}
Loading