diff --git a/src/Symfony/Component/HttpClient/Internal/ResponseRecorder.php b/src/Symfony/Component/HttpClient/Internal/ResponseRecorder.php new file mode 100644 index 000000000000..a9684059c6ea --- /dev/null +++ b/src/Symfony/Component/HttpClient/Internal/ResponseRecorder.php @@ -0,0 +1,68 @@ + + * + * 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 + * + * @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)); + } +} diff --git a/src/Symfony/Component/HttpClient/RecordReplayCallback.php b/src/Symfony/Component/HttpClient/RecordReplayCallback.php new file mode 100644 index 000000000000..edb4fe8b5e93 --- /dev/null +++ b/src/Symfony/Component/HttpClient/RecordReplayCallback.php @@ -0,0 +1,140 @@ + + * + * 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 + */ +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; + } +} diff --git a/src/Symfony/Component/HttpClient/Response/ResponseSerializer.php b/src/Symfony/Component/HttpClient/Response/ResponseSerializer.php new file mode 100644 index 000000000000..7ada3b4d5864 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Response/ResponseSerializer.php @@ -0,0 +1,75 @@ + + * + * 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 + */ +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 $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); + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/RecordReplayCallbackTest.php b/src/Symfony/Component/HttpClient/Tests/RecordReplayCallbackTest.php new file mode 100644 index 000000000000..ae2f0915a574 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/RecordReplayCallbackTest.php @@ -0,0 +1,74 @@ + + * + * 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); + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/Response/ResponseSerializerTest.php b/src/Symfony/Component/HttpClient/Tests/Response/ResponseSerializerTest.php new file mode 100644 index 000000000000..b9766e113d9c --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/Response/ResponseSerializerTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Tests\Response; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\Response\ResponseSerializer; +use Symfony\Contracts\HttpClient\ResponseInterface; + +class ResponseSerializerTest extends TestCase +{ + /** + * @var ResponseSerializer + */ + private $serializer; + + protected function setUp(): void + { + $this->serializer = new ResponseSerializer(); + } + + public function testSerialize() + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeaders')->willReturn([ + 'Content-Type' => ['application/json'], + 'Cache-Control' => ['no-cache', 'private'], + ]); + $response->method('getContent')->willReturn('{"foo": true}'); + + $expected = <<<'EOL' +200 + +content-type: application/json +cache-control: no-cache, private + +{"foo": true} +EOL; + + $this->assertSame($expected, $this->serializer->serialize($response)); + } + + public function testDeserialize() + { + $content = <<<'EOL' +200 + +content-type: application/json +cache-control: no-cache, private +x-robots-tag: noindex + +{"foo": true} +EOL; + $this->assertSame( + [ + 200, + [ + 'content-type' => ['application/json'], + 'cache-control' => ['no-cache', 'private'], + 'x-robots-tag' => ['noindex'], + ], + '{"foo": true}', + ], + $this->serializer->deserialize($content) + ); + } +} diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index 6e8fc636548f..b469175296e1 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -38,6 +38,7 @@ "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", "symfony/dependency-injection": "^4.4|^5.0", + "symfony/filesystem": "^4.4|^5.0", "symfony/http-kernel": "^4.4.13|^5.1.5", "symfony/process": "^4.4|^5.0" },