Skip to content

Commit

Permalink
[HttpClient] Add a Record & Replay callback to the MockHttpClient.
Browse files Browse the repository at this point in the history
  • Loading branch information
GaryPEGEOT committed Feb 11, 2020
1 parent 9eb7cb1 commit 8b70fec
Show file tree
Hide file tree
Showing 4 changed files with 231 additions and 23 deletions.
24 changes: 24 additions & 0 deletions src/Symfony/Component/HttpClient/HttpClientTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\HttpClient;

use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;

/**
* Provides the common logic from writing HttpClientInterface implementations.
Expand Down Expand Up @@ -305,6 +306,29 @@ private static function normalizeBody($body)
return $body;
}

private static function getBodyAsString($body): string
{
if (\is_resource($body)) {
return stream_get_contents($body);
}

if (!$body instanceof \Closure) {
return $body;
}

$result = '';

while ('' !== $data = $body(self::$CHUNK_SIZE)) {
if (!\is_string($data)) {
throw new TransportException(sprintf('Return value of the "body" option callback must be string, %s returned.', \gettype($data)));
}

$result .= $data;
}

return $result;
}

/**
* @param string|string[] $fingerprint
*
Expand Down
23 changes: 0 additions & 23 deletions src/Symfony/Component/HttpClient/NativeHttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,29 +244,6 @@ public function stream($responses, float $timeout = null): ResponseStreamInterfa
return new ResponseStream(NativeResponse::stream($responses, $timeout));
}

private static function getBodyAsString($body): string
{
if (\is_resource($body)) {
return stream_get_contents($body);
}

if (!$body instanceof \Closure) {
return $body;
}

$result = '';

while ('' !== $data = $body(self::$CHUNK_SIZE)) {
if (!\is_string($data)) {
throw new TransportException(sprintf('Return value of the "body" option callback must be string, %s returned.', \gettype($data)));
}

$result .= $data;
}

return $result;
}

/**
* Loads proxy configuration from the same environment variables as curl when no proxy is explicitly set.
*/
Expand Down
128 changes: 128 additions & 0 deletions src/Symfony/Component/HttpClient/RecordReplayCallback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?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 Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

/**
* A callback for the MockHttpClient. Three modes available:
* - MODE_RECORD -> Make an actual HTTP request and save the response, overriding any pre-existing response.
* - MODE_REPLAY -> Will try to replay an existing response, and throw an exception if none is found
* - MODE_REPLAY_OR_RECORD -> Try to replay response if possible, otherwise make an actual HTTP request and save it.
*
* @author Gary PEGEOT <gary.pegeot@allopneus.com>
*/
class RecordReplayCallback implements LoggerAwareInterface
{
use HttpClientTrait;
use LoggerAwareTrait;

public const MODE_RECORD = 'record';
public const MODE_REPLAY = 'replay';
public const MODE_REPLAY_OR_RECORD = 'replay_or_record';

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

/**
* @var CacheItemPoolInterface
*/
private $cache;

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

public function __construct(string $mode, CacheItemPoolInterface $cache, HttpClientInterface $client = null)
{
$this->mode = $mode;
$this->cache = $cache;
$this->client = $client ?? HttpClient::create();
}

public function __invoke(string $method, string $url, array $options = []): ResponseInterface
{
$parts = [$method, $url];
$response = null;

if (isset($options['body'])) {
$parts[] = md5(static::getBodyAsString($options['body']));
}

$key = md5(implode(';', $parts));

$this->log('Calculated key "{key}" for {method} request to "{url}".', compact('key', 'method', 'url'));
if (static::MODE_REPLAY === $this->mode && !$this->cache->hasItem($key)) {
$this->log('Unable to replay response.');
throw new TransportException("Unable to replay response for $method request to \"$url\" endpoint.");
}

if (static::MODE_RECORD !== $this->mode && $this->cache->hasItem($key)) {
$this->log('Cache hit, replaying response.');
$cached = $this->cache->getItem($key)->get();

return new MockResponse($cached['body'], $cached['info']);
}

$this->log('Cache miss, recording response.');
$response = $this->client->request($method, $url, $options);
$response->getHeaders(false);
$info = $response->getInfo();

if (isset($info['user_data'])) {
unset($info['user_data']);
}

$body = [];
foreach ($this->client->stream($response) as $chunk) {
if ($chunk->isTimeout()) {
break;
}

if ('' !== $content = $chunk->getContent()) {
$body[] = $content;
}
}

$item = $this->cache->getItem($key);
$item->set(compact('body', 'info'));
$this->cache->save($item);

return new MockResponse($body, $info);
}

/**
* @return $this
*/
public function setMode(string $mode): self
{
$this->mode = $mode;

return $this;
}

private function log(string $message, array $context = []): void
{
$context['mode'] = strtoupper($this->mode);

$this->logger && $this->logger->debug("Request [{mode}]: $message", $context);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?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 Psr\Log\Test\TestLogger as Logger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\RecordReplayCallback;

class RecordReplayCallbackTest extends TestCase
{
/**
* @var ArrayAdapter
*/
private $cache;

/**
* @var Logger
*/
private $logger;

/**
* @var RecordReplayCallback
*/
private $callback;

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

protected function setUp(): void
{
$this->cache = new ArrayAdapter();
$this->logger = new Logger();
$this->callback = new RecordReplayCallback(RecordReplayCallback::MODE_REPLAY_OR_RECORD, $this->cache);
$this->callback->setLogger($this->logger);
$this->client = new MockHttpClient($this->callback);
}

public function testReplayOrRecord(): void
{
$response = $this->client->request('GET', 'http://localhost:8057');
$response->getHeaders(false);

$this->assertCount(1, $this->cache->getValues(), 'response should be cached');

$this->logger->reset();
$replayed = $this->client->request('GET', 'http://localhost:8057');
$replayed->getHeaders(false);

$this->assertSame($response->getContent(), $replayed->getContent());
$this->assertSame($response->getInfo()['response_headers'], $replayed->getInfo()['response_headers']);
$this->assertCount(1, $this->cache->getValues(), 'Cache should not store twice.');

$this->assertTrue($this->logger->hasDebugThatContains('Cache hit, replaying response'), 'Response should be replayed');
}

public function testReplayThrowWhenNoRecordIsFound(): void
{
$this->expectException(TransportException::class);
$this->expectExceptionMessage('Unable to replay response for GET request to "http://localhost:8057/" endpoint.');

$this->callback->setMode(RecordReplayCallback::MODE_REPLAY);
$response = $this->client->request('GET', 'http://localhost:8057');
$response->getHeaders(false);
}
}

0 comments on commit 8b70fec

Please sign in to comment.