Skip to content
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

[HttpClient] Add a Record & Replay callback to the MockHttpClient. #35677

Closed
wants to merge 4 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/Symfony/Component/HttpClient/Internal/ResponseRecorder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?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\Internal;

use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpClient\Response\ResponseSerializer;
use Symfony\Contracts\HttpClient\ResponseInterface;

/**
* Stores and extract responses on the filesystem.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*
* @internal
*/
class ResponseRecorder
{
/**
* @var string
*/
private $fixtureDir;

/**
* @var ResponseSerializer
*/
private $serializer;

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

public function __construct(string $fixtureDir, ResponseSerializer $serializer, ?Filesystem $filesystem = null)
{
$this->fixtureDir = realpath($fixtureDir);
$this->serializer = $serializer;
$this->filesystem = $filesystem ?? new Filesystem();

if (false === $this->fixtureDir) {
throw new \InvalidArgumentException(sprintf('Invalid fixture directory "%s" provided.', $fixtureDir));
}
}

public function record(string $key, ResponseInterface $response): void
{
$this->filesystem->dumpFile("{$this->fixtureDir}/$key.txt", $this->serializer->serialize($response));
}

public function replay(string $key): ?array
{
$filename = "{$this->fixtureDir}/$key.txt";

if (!is_file($filename)) {
return null;
}

return $this->serializer->deserialize(file_get_contents($filename));
}
}
140 changes: 140 additions & 0 deletions src/Symfony/Component/HttpClient/RecordReplayCallback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?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\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\ResponseRecorder;
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@gmail.com>
*/
class RecordReplayCallback implements LoggerAwareInterface
{
use LoggerAwareTrait;

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

private $mode;

private $client;

private $recorder;

public function __construct(ResponseRecorder $recorder, string $mode = 'replay_or_record', HttpClientInterface $client = null)
{
$this->recorder = $recorder;
$this->mode = $mode;
$this->client = $client ?? HttpClient::create();
$this->logger = new NullLogger();
}

public function __invoke(string $method, string $url, array $options = []): ResponseInterface
{
$useHash = false;
$ctx = hash_init('SHA512');
$parts = [$method, $url];
$response = null;

if ($body = ($options['body'] ?? null)) {
hash_update($ctx, $body);
$useHash = true;
}

if (!empty($options['query'])) {
hash_update($ctx, http_build_query($options['query']));
$useHash = true;
}

foreach ($options['headers'] as $name => $values) {
hash_update($ctx, sprintf('%s:%s', $name, implode(',', $values)));
$useHash = true;
}

if ($useHash) {
$parts[] = substr(hash_final($ctx), 0, 6);
}

$key = strtr(implode('-', $parts), ':/\\', '-');

$this->log('Calculated key "{key}" for {method} request to "{url}".', [
'key' => $key,
'method' => $method,
'url' => $url,
]);

if (static::MODE_RECORD === $this->mode) {
return $this->recordResponse($key, $method, $url, $options);
}

$replayed = $this->recorder->replay($key);

if (null !== $replayed) {
[$statusCode, $headers, $body] = $replayed;

$this->log('Response replayed.');

return new MockResponse($body, [
'http_code' => $statusCode,
'response_headers' => $headers,
'user_data' => $options['user_data'] ?? null,
]);
}

if (static::MODE_REPLAY === $this->mode) {
$this->log('Unable to replay response.');

throw new TransportException("Unable to replay response for $method request to \"$url\" endpoint.");
}

return $this->recordResponse($key, $method, $url, $options);
}

/**
* @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->debug("[HTTP_CLIENT][{mode}]: $message", $context);
}

private function recordResponse(string $key, string $method, string $url, array $options): ResponseInterface
{
$response = $this->client->request($method, $url, $options);
$this->recorder->record($key, $response);

$this->log('Response recorded.');

return $response;
}
}
75 changes: 75 additions & 0 deletions src/Symfony/Component/HttpClient/Response/ResponseSerializer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?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;

/**
* Turns a ResponseInterface to a string and vice-versa. Generated string should be modifiable easily.
*
* @author Gary PEGEOT <gary.pegeot@allopneus.com>
*/
class ResponseSerializer
{
private const SEPARATOR = \PHP_EOL.\PHP_EOL;

public function serialize(ResponseInterface $response): string
{
$parts = [
$response->getStatusCode(),
$this->serializeHeaders($response->getHeaders(false)),
$response->getContent(false),
];

return implode(static::SEPARATOR, $parts);
}

public function deserialize(string $content): array
{
[$statusCode, $unparsedHeaders, $body] = explode(static::SEPARATOR, $content, 3);
$headers = [];

foreach (explode(\PHP_EOL, $unparsedHeaders) as $row) {
[$name, $values] = explode(':', $row, 2);
$name = strtolower(trim($name));

if ('set-cookie' === $name) {
$headers[$name][] = trim($values);
} else {
$headers[$name] = array_map('trim', explode(',', $values));
}
}

return [(int) $statusCode, $headers, $body];
}

/**
* @param array<string, string[]> $headers
*/
private function serializeHeaders(array $headers): string
{
$parts = [];
foreach ($headers as $name => $values) {
$name = strtolower(trim($name));

if ('set-cookie' === strtolower($name)) {
foreach ($values as $value) {
$parts[] = "{$name}: {$value}";
}
} else {
$parts[] = sprintf('%s: %s', $name, implode(', ', $values));
}
}

return implode(\PHP_EOL, $parts);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?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 Psr\Log\Test\TestLogger as Logger;
use Symfony\Component\Filesystem\Tests\FilesystemTestCase;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\ResponseRecorder;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\RecordReplayCallback;
use Symfony\Component\HttpClient\Response\ResponseSerializer;

class RecordReplayCallbackTest extends FilesystemTestCase
{
/**
* @var Logger
*/
private $logger;

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

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

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

$this->logger = new Logger();
$this->callback = new RecordReplayCallback($recorder);
$this->callback->setLogger($this->logger);
$this->client = new MockHttpClient($this->callback);
}

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

$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->assertTrue($this->logger->hasDebugThatContains('Response replayed'), 'Response should be replayed');
}

public function testReplayThrowWhenNoRecordIsFound()
{
$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', ['query' => ['foo' => 'bar']]);
$response->getHeaders(false);
}
}