8000 [HttpClient] Add a Record & Replay callback to the MockHttpClient. by GaryPEGEOT · Pull Request #35677 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

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

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 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 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
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));
}
}
146 changes: 146 additions & 0 deletions src/Symfony/Component/HttpClient/RecordReplayCallback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?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\Component\String\Slugger\AsciiSlugger;
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';

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

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

/**
* @var ResponseRecorder
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's remove all @var that duplicate the constructor, they're useless

*/
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 = (new AsciiSlugger())->slug(implode('-', $parts))->toString();

$this->log('Calculated key "{key}" for {method} request to "{url}".', compact('key', 'method', '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,73 @@
<?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\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 TestCase
{
/**
* @var Logger
*/
private $logger;

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

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

protected function setUp(): void
{
$recorder = new ResponseRecorder(sys_get_temp_dir(), new ResponseSerializer());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should clean up the temporary files created when running the tests


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

public function testReplayOrRecord(): void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove all : void from all test cases

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