From 61683f50a8ccddf4b13298037aca0ec4465c0ce2 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Sun, 24 Mar 2019 14:36:58 +0000 Subject: [PATCH 1/8] [Plugin] Add VCR Record & Replay plugins --- .php_cs | 13 -- README.md | 17 ++ composer.json | 30 +-- phpunit.xml.dist | 7 +- src/Exception/CannotBeReplayed.php | 7 - src/Exception/InvalidState.php | 7 - src/Exception/NotFound.php | 7 - src/Exception/Storage.php | 7 - src/Exception/VcrException.php | 7 - .../NamingStrategyInterface.php | 17 ++ src/NamingStrategy/PathNamingStrategy.php | 85 ++++++++ src/RecordPlugin.php | 50 +++++ src/Recorder/FilesystemRecorder.php | 80 ++++++++ src/Recorder/InMemoryRecorder.php | 35 ++++ src/Recorder/PlayerInterface.php | 17 ++ src/Recorder/RecorderInterface.php | 17 ++ src/ReplayPlugin.php | 47 +++++ src/Storage.php | 26 --- src/Storage/FileStorage.php | 54 ------ src/Storage/InMemoryStorage.php | 47 ----- src/Tape.php | 61 ------ src/Track.php | 76 -------- src/Vcr.php | 158 --------------- src/VcrClient.php | 31 --- src/VcrPlugin.php | 128 ------------ src/VcrTestListener.php | 18 -- tests/AbstractPluginTestCase.php | 52 +++++ tests/ClientImplementation.php | 10 - .../NamingStrategy/PathNamingStrategyTest.php | 75 ++++++++ tests/RecordPluginTest.php | 38 ++++ tests/Recorder/FilesystemRecorderTest.php | 57 ++++++ tests/Recorder/InMemoryRecorderTest.php | 43 +++++ tests/ReplayPluginTest.php | 45 +++++ tests/Storage/FileStorageTest.php | 91 --------- tests/Storage/InMemoryStorageTest.php | 48 ----- tests/TapeTest.php | 52 ----- tests/TrackTest.php | 80 -------- tests/VcrClientTest.php | 59 ------ tests/VcrPluginTest.php | 182 ------------------ tests/VcrTest.php | 129 ------------- tests/VcrTestCase.php | 56 ------ tests/bootstrap.php | 6 - 42 files changed, 696 insertions(+), 1376 deletions(-) delete mode 100644 .php_cs delete mode 100644 src/Exception/CannotBeReplayed.php delete mode 100644 src/Exception/InvalidState.php delete mode 100644 src/Exception/NotFound.php delete mode 100644 src/Exception/Storage.php delete mode 100644 src/Exception/VcrException.php create mode 100644 src/NamingStrategy/NamingStrategyInterface.php create mode 100644 src/NamingStrategy/PathNamingStrategy.php create mode 100644 src/RecordPlugin.php create mode 100644 src/Recorder/FilesystemRecorder.php create mode 100644 src/Recorder/InMemoryRecorder.php create mode 100644 src/Recorder/PlayerInterface.php create mode 100644 src/Recorder/RecorderInterface.php create mode 100644 src/ReplayPlugin.php delete mode 100644 src/Storage.php delete mode 100644 src/Storage/FileStorage.php delete mode 100644 src/Storage/InMemoryStorage.php delete mode 100644 src/Tape.php delete mode 100644 src/Track.php delete mode 100644 src/Vcr.php delete mode 100644 src/VcrClient.php delete mode 100644 src/VcrPlugin.php delete mode 100644 src/VcrTestListener.php create mode 100644 tests/AbstractPluginTestCase.php delete mode 100644 tests/ClientImplementation.php create mode 100644 tests/NamingStrategy/PathNamingStrategyTest.php create mode 100644 tests/RecordPluginTest.php create mode 100644 tests/Recorder/FilesystemRecorderTest.php create mode 100644 tests/Recorder/InMemoryRecorderTest.php create mode 100644 tests/ReplayPluginTest.php delete mode 100644 tests/Storage/FileStorageTest.php delete mode 100644 tests/Storage/InMemoryStorageTest.php delete mode 100644 tests/TapeTest.php delete mode 100644 tests/TrackTest.php delete mode 100644 tests/VcrClientTest.php delete mode 100644 tests/VcrPluginTest.php delete mode 100644 tests/VcrTest.php delete mode 100644 tests/VcrTestCase.php delete mode 100644 tests/bootstrap.php diff --git a/.php_cs b/.php_cs deleted file mode 100644 index 23ba165..0000000 --- a/.php_cs +++ /dev/null @@ -1,13 +0,0 @@ - @@ -13,7 +12,7 @@ - . + src ./tests ./vendor diff --git a/src/Exception/CannotBeReplayed.php b/src/Exception/CannotBeReplayed.php deleted file mode 100644 index 396fcdb..0000000 --- a/src/Exception/CannotBeReplayed.php +++ /dev/null @@ -1,7 +0,0 @@ - + */ +interface NamingStrategyInterface +{ + public function name(RequestInterface $request): string; +} diff --git a/src/NamingStrategy/PathNamingStrategy.php b/src/NamingStrategy/PathNamingStrategy.php new file mode 100644 index 0000000..0000126 --- /dev/null +++ b/src/NamingStrategy/PathNamingStrategy.php @@ -0,0 +1,85 @@ + + */ +class PathNamingStrategy implements NamingStrategyInterface +{ + /** + * @var array + */ + private $options; + + public function __construct(array $options = []) + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $this->options = $resolver->resolve($options); + } + + public function name(RequestInterface $request): string + { + $parts = [$this->options['name_prefix']]; + + if ($this->options['hostname_prefix']) { + $parts[] = $request->getUri()->getHost(); + } + $method = strtoupper($request->getMethod()); + + $parts[] = $method; + $parts[] = str_replace(\DIRECTORY_SEPARATOR, '_', trim($request->getUri()->getPath(), '/')); + + if ($query = $request->getUri()->getQuery()) { + $parts[] = $this->hash($query); + } + + if ($this->options['use_headers']) { + $headers = ''; + foreach ($request->getHeaders() as $header => $values) { + $headers .= "$header:".implode(',', $values); + } + + $parts[] = $this->hash($headers); + } + + if (\in_array($method, $this->options['hash_body_methods'], true)) { + $parts[] = $this->hash((string) $request->getBody()); + } + + return implode('_', array_filter($parts)); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'hostname_prefix' => false, + 'name_prefix' => '', + 'use_headers' => false, + 'hash_body_methods' => ['PUT', 'POST', 'PATCH'], + ]); + + $resolver->setAllowedTypes('hostname_prefix', 'bool'); + $resolver->setAllowedTypes('name_prefix', ['null', 'string']); + $resolver->setAllowedTypes('use_headers', 'bool'); + $resolver->setAllowedTypes('hash_body_methods', 'string[]'); + + $resolver->setNormalizer('hash_body_methods', function (Options $options, $value) { + return \is_array($value) ? array_map('strtoupper', $value) : $value; + }); + } + + private function hash(string $value): string + { + return substr(sha1($value), 0, 5); + } +} diff --git a/src/RecordPlugin.php b/src/RecordPlugin.php new file mode 100644 index 0000000..2ed3885 --- /dev/null +++ b/src/RecordPlugin.php @@ -0,0 +1,50 @@ +namingStrategy = $namingStrategy; + $this->recorder = $recorder; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise + { + $name = $this->namingStrategy->name($request); + + return $next($request)->then(function (ResponseInterface $response) use ($name) { + if ($response->getStatusCode() < 300 && !$response->hasHeader(ReplayPlugin::HEADER_NAME)) { + $this->recorder->record($name, $response); + $response = $response->withAddedHeader(static::HEADER_NAME, $name); + } + + return $response; + }); + } +} diff --git a/src/Recorder/FilesystemRecorder.php b/src/Recorder/FilesystemRecorder.php new file mode 100644 index 0000000..74ecf2e --- /dev/null +++ b/src/Recorder/FilesystemRecorder.php @@ -0,0 +1,80 @@ + + */ +final class FilesystemRecorder implements RecorderInterface, PlayerInterface, LoggerAwareInterface +{ + use LoggerAwareTrait; + + /** + * @var string + */ + private $directory; + + /** + * @var Filesystem + */ + private $filesystem; + + public function __construct(string $directory, ?Filesystem $filesystem = null) + { + $this->filesystem = $filesystem ?? new Filesystem(); + + if (!$this->filesystem->exists($directory)) { + try { + $this->filesystem->mkdir($directory); + } catch (IOException $e) { + throw new \InvalidArgumentException("Unable to create directory \"$directory\"/: {$e->getMessage()}", $e->getCode(), $e); + } + } + + $this->directory = realpath($directory).\DIRECTORY_SEPARATOR; + } + + public function replay(string $name): ?ResponseInterface + { + $filename = "{$this->directory}$name.txt"; + $context = compact('filename'); + + if (!$this->filesystem->exists($filename)) { + $this->log('Unable to replay {filename}', $context); + + return null; + } + + $this->log('Response replayed from {filename}', $context); + + return Psr7\parse_response(file_get_contents($filename)); + } + + public function record(string $name, ResponseInterface $response): void + { + $filename = "{$this->directory}$name.txt"; + $context = compact('name', 'filename'); + + $this->filesystem->dumpFile($filename, Psr7\str($response)); + + $this->log('Response for {name} stored into {filename}', $context); + } + + private function log(string $message, array $context = []): void + { + if ($this->logger) { + $this->logger->debug("[VCR-PLUGIN][FilesystemRecorder] $message", $context); + } + } +} diff --git a/src/Recorder/InMemoryRecorder.php b/src/Recorder/InMemoryRecorder.php new file mode 100644 index 0000000..7f137ea --- /dev/null +++ b/src/Recorder/InMemoryRecorder.php @@ -0,0 +1,35 @@ + + */ +final class InMemoryRecorder implements PlayerInterface, RecorderInterface +{ + /** + * @var ResponseInterface[] + */ + private $responses = []; + + public function replay(string $name): ?ResponseInterface + { + return $this->responses[$name] ?? null; + } + + public function record(string $name, ResponseInterface $response): void + { + $this->responses[$name] = $response; + } + + public function clear(): void + { + $this->responses = []; + } +} diff --git a/src/Recorder/PlayerInterface.php b/src/Recorder/PlayerInterface.php new file mode 100644 index 0000000..77e151f --- /dev/null +++ b/src/Recorder/PlayerInterface.php @@ -0,0 +1,17 @@ + + */ +interface PlayerInterface +{ + public function replay(string $name): ?ResponseInterface; +} diff --git a/src/Recorder/RecorderInterface.php b/src/Recorder/RecorderInterface.php new file mode 100644 index 0000000..07d28f6 --- /dev/null +++ b/src/Recorder/RecorderInterface.php @@ -0,0 +1,17 @@ + + */ +interface RecorderInterface +{ + public function record(string $name, ResponseInterface $response): void; +} diff --git a/src/ReplayPlugin.php b/src/ReplayPlugin.php new file mode 100644 index 0000000..3d9f3c6 --- /dev/null +++ b/src/ReplayPlugin.php @@ -0,0 +1,47 @@ +namingStrategy = $namingStrategy; + $this->player = $player; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise + { + $name = $this->namingStrategy->name($request); + + if ($response = $this->player->replay($name)) { + return new FulfilledPromise($response->withAddedHeader(static::HEADER_NAME, $name)); + } + + return $next($request); + } +} diff --git a/src/Storage.php b/src/Storage.php deleted file mode 100644 index 862f939..0000000 --- a/src/Storage.php +++ /dev/null @@ -1,26 +0,0 @@ -dir = $dir; - } - - public function store(Tape $tape) - { - $filePath = $this->getFilePathForName($tape->getName()); - - file_put_contents($filePath, serialize($tape)); - } - - public function fetch($name) - { - $filePath = $this->getFilePathForName($name); - - if (!file_exists($filePath)) { - throw new Exception\NotFound(sprintf('Tape with name "%s" not found.', $name)); - } - - return unserialize(file_get_contents($filePath)); - } - - private function getFilePathForName($name) - { - return $this->dir.DIRECTORY_SEPARATOR.$name; - } -} diff --git a/src/Storage/InMemoryStorage.php b/src/Storage/InMemoryStorage.php deleted file mode 100644 index eb02277..0000000 --- a/src/Storage/InMemoryStorage.php +++ /dev/null @@ -1,47 +0,0 @@ -tapes = []; - - foreach ($tapes as $tape) { - $this->store($tape); - } - } - - public function store(Tape $tape) - { - $key = $this->createKey($tape->getName()); - - $this->tapes[$key] = $tape; - } - - public function fetch($name) - { - $key = $this->createKey($name); - - if (!array_key_exists($key, $this->tapes)) { - throw new Exception\NotFound(sprintf('Tape with name "%s" not found.', $name)); - } - - return $this->tapes[$key]; - } - - private function createKey($name) - { - return md5($name); - } -} diff --git a/src/Tape.php b/src/Tape.php deleted file mode 100644 index 759c4c9..0000000 --- a/src/Tape.php +++ /dev/null @@ -1,61 +0,0 @@ -name = $name; - $this->tracks = []; - } - - public static function create($name) - { - return new self($name); - } - - public function getName() - { - return $this->name; - } - - public function addTrack(Track $track) - { - $this->tracks[] = $track; - } - - /** - * @param RequestInterface $request - * - * @throws Exception\NotFound - * - * @return Track - */ - public function findTrackByRequest(RequestInterface $request) - { - $requestMethod = $request->getMethod(); - $requestUriString = (string) $request->getUri(); - - foreach ($this->tracks as $track) { - $trackRequest = $track->getRequest(); - if ($trackRequest->getMethod() === $requestMethod && (string) $trackRequest->getUri() === $requestUriString) { - return $track; - } - } - - throw new Exception\NotFound(sprintf('No track found for %s Request to %s.', $requestMethod, $requestUriString)); - } -} diff --git a/src/Track.php b/src/Track.php deleted file mode 100644 index c37ee4d..0000000 --- a/src/Track.php +++ /dev/null @@ -1,76 +0,0 @@ -request = $request; - $this->response = $response; - $this->exception = $exception; - } - - public static function create(RequestInterface $request) - { - return new self($request); - } - - public function getRequest() - { - return $this->request; - } - - public function hasResponse() - { - return (bool) $this->response; - } - - public function setResponse(ResponseInterface $response) - { - $this->response = $response; - } - - public function getResponse() - { - if ($this->response && $body = $this->response->getBody()) { - // Rewind response body in case it has already been read - $body->seek(0, SEEK_SET); - } - - return $this->response; - } - - public function hasException() - { - return (bool) $this->exception; - } - - public function setException(\Exception $exception) - { - $this->exception = $exception; - } - - public function getException() - { - return $this->exception; - } -} diff --git a/src/Vcr.php b/src/Vcr.php deleted file mode 100644 index 424ff3c..0000000 --- a/src/Vcr.php +++ /dev/null @@ -1,158 +0,0 @@ -storage = $storage ?: new InMemoryStorage(); - - $this->isTurnedOn = false; - $this->isRecording = false; - } - - public static function createWithStorage(Storage $storage) - { - return new self($storage); - } - - public function turnOn() - { - $this->isTurnedOn = true; - } - - public function turnOff() - { - $this->isTurnedOn = false; - } - - public function isTurnedOn() - { - return $this->isTurnedOn; - } - - /** - * Starts recording. - * - * @throws InvalidState if no tape has been inserted - */ - public function startRecording() - { - if (!$this->isTurnedOn()) { - throw new InvalidState('Please turn me on first.'); - } - - if (!$this->hasTape()) { - throw new InvalidState('Please insert a tape first.'); - } - - $this->isRecording = true; - } - - /** - * Stops recording. - */ - public function stopRecording() - { - $this->isRecording = false; - } - - /** - * Returns whether the Vcr is currently recording or not. - * - * @return bool - */ - public function isRecording() - { - return $this->isRecording; - } - - /** - * Returns whether a tape is currently inserted or not. - * - * @return bool - */ - public function hasTape() - { - return (bool) $this->tape; - } - - /** - * Returns the currently inserted tape. - * - * @return Tape - */ - public function getTape() - { - if (!$this->tape) { - throw new InvalidState('Please insert a tape first.'); - } - - return $this->tape; - } - - /** - * Inserts the given tape. - * - * If an actual tape is provided, it is inserted directly. - * If the name of a tape is provided, it will be fetched from the shelf, or created with the given name. - * - * @param Tape|string $tape - * - * @throws InvalidState if the tape could not be inserted - */ - public function insert($tape) - { - if ($this->tape) { - throw new InvalidState(sprintf('Please eject the tape "%s" first.', $this->tape->getName())); - } - - if (!($tape instanceof Tape)) { - try { - $tape = $this->storage->fetch($tape); - } catch (NotFound $e) { - $tape = new Tape($tape); - } - } - - $this->tape = $tape; - } - - /** - * Ejects the currently inserted tape. - */ - public function eject() - { - if ($this->tape) { - $this->storage->store($this->tape); - $this->tape = null; - } - } -} diff --git a/src/VcrClient.php b/src/VcrClient.php deleted file mode 100644 index 3b65fec..0000000 --- a/src/VcrClient.php +++ /dev/null @@ -1,31 +0,0 @@ -client = new PluginClient($client, [new VcrPlugin($vcr)]); - } - - public function sendRequest(RequestInterface $request) - { - return $this->client->sendRequest($request); - } - - public function sendAsyncRequest(RequestInterface $request) - { - return $this->client->sendAsyncRequest($request); - } -} diff --git a/src/VcrPlugin.php b/src/VcrPlugin.php deleted file mode 100644 index b86dc6d..0000000 --- a/src/VcrPlugin.php +++ /dev/null @@ -1,128 +0,0 @@ -vcr = $vcr; - } - - public function handleRequest(RequestInterface $request, callable $next, callable $first) - { - try { - return $this->replay($request); - } catch (CannotBeReplayed $e) { - $this->record($request); - } - - return $next($request)->then($this->onFulfilled($request), $this->onRejected($request)); - } - - private function replay(RequestInterface $request) - { - if (!$this->vcr->isTurnedOn() || !$this->vcr->hasTape()) { - throw new CannotBeReplayed(); - } - - $tape = $this->vcr->getTape(); - - try { - $track = $tape->findTrackByRequest($request); - } catch (NotFound $e) { - throw new CannotBeReplayed(); - } - - if ($track->hasException()) { - return new RejectedPromise($track->getException()); - } - - if ($track->hasResponse()) { - $response = $track->getResponse()->withAddedHeader(self::HEADER_VCR, self::HEADER_VCR_REPLAY); - - return new FulfilledPromise($response); - } - - throw new CannotBeReplayed(); - } - - private function record(RequestInterface $request) - { - if (!$this->vcr->isTurnedOn() || !$this->vcr->hasTape()) { - return; - } - - $tape = $this->vcr->getTape(); - $tape->addTrack(Track::create($request)); - } - - private function onFulfilled(RequestInterface $request) - { - $vcr = $this->vcr; - - return function (ResponseInterface $response) use ($vcr, $request) { - if ($vcr->isTurnedOn() && $vcr->isRecording()) { - $tape = $vcr->getTape(); - - try { - $track = $tape->findTrackByRequest($request); - } catch (NotFound $e) { - // The track should have been added already when the request was handled initially, - // but who knows what weird stuff you are doing :) - $track = Track::create($request); - $tape->addTrack($track); - } - - $track->setResponse($response); - - $response = $response->withAddedHeader(self::HEADER_VCR, self::HEADER_VCR_RECORDED); - } - - return $response; - }; - } - - private function onRejected(RequestInterface $request) - { - $vcr = $this->vcr; - - return function (\Exception $e) use ($vcr, $request) { - if ($vcr->isTurnedOn() && $vcr->isRecording()) { - $tape = $vcr->getTape(); - - try { - $track = $tape->findTrackByRequest($request); - } catch (NotFound $notFound) { - // The track should have been added already when the request was handled initially, - // but who knows what weird stuff you are doing :) - $track = Track::create($request); - $tape->addTrack($track); - } - - $track->setException($e); - } - - return $e; - }; - } -} diff --git a/src/VcrTestListener.php b/src/VcrTestListener.php deleted file mode 100644 index 0f77b07..0000000 --- a/src/VcrTestListener.php +++ /dev/null @@ -1,18 +0,0 @@ -getPluginClass(); + $this->namingStrategy = $this->createMock(NamingStrategyInterface::class); + $this->recorder = new InMemoryRecorder(); + $this->plugin = new $pluginClass($this->namingStrategy, $this->recorder); + } + + protected function getRequest(): RequestInterface + { + return $this->createMock(RequestInterface::class); + } + + protected function failCallback(): callable + { + return function (): Promise { + $this->fail('Never called'); + }; + } + + abstract protected function getPluginClass(): string; +} diff --git a/tests/ClientImplementation.php b/tests/ClientImplementation.php deleted file mode 100644 index f05dfb5..0000000 --- a/tests/ClientImplementation.php +++ /dev/null @@ -1,10 +0,0 @@ -assertSame($expected, $strategy->name($request)); + } + + public function testConfigureOptions(): void + { + $resolver = new OptionsResolver(); + + $expected = [ + 'hostname_prefix' => false, + 'name_prefix' => '', + 'use_headers' => false, + 'hash_body_methods' => ['PUT'], + ]; + + (new PathNamingStrategy())->configureOptions($resolver); + + $this->assertSame($expected, $resolver->resolve(['hash_body_methods' => ['put']])); + } + + public function provideRequests(): \Generator + { + yield 'Simple GET request' => ['GET_my-path_my-sub-path', $this->getRequest('/my-path/my-sub-path')]; + + yield 'GET request with query' => ['GET_my-path_2fb8f', $this->getRequest('/my-path?foo=bar')]; + + yield 'GET request with different query' => ['GET_my-path_daa84', $this->getRequest('/my-path?foo=baz')]; + + yield 'GET request with hostname' => [ + 'example.org_GET_my-path', + $this->getRequest('https://example.org/my-path'), + ['hostname_prefix' => true], + ]; + + yield 'Custom prefix' => ['foo_GET_my-path', $this->getRequest('/my-path'), ['name_prefix' => 'foo']]; + + yield 'Header hash' => ['GET_my-path_76a1f', $this->getRequest('/my-path'), ['use_headers' => true]]; + + yield 'Body hash' => ['POST_my-action_d3b09', $this->getRequest('/my-action', 'POST', '{"hello": "world"}')]; + + yield 'Full package' => [ + 'POST_my-action_1a3b6_76a1f_d3b09', + $this->getRequest('/my-action?page=1', 'POST', '{"hello": "world"}'), + ['use_headers' => true], + ]; + } + + private function getRequest(string $uri, string $method = 'GET', ?string $body = null): RequestInterface + { + return new Request($method, $uri, ['X-Foo' => 'Bar'], $body); + } +} diff --git a/tests/RecordPluginTest.php b/tests/RecordPluginTest.php new file mode 100644 index 0000000..154a29b --- /dev/null +++ b/tests/RecordPluginTest.php @@ -0,0 +1,38 @@ +namingStrategy->method('name')->willReturn('foo'); + $next = function () use ($response): Promise { + return new FulfilledPromise($response); + }; + + $this->plugin->handleRequest($this->getRequest(), $next, $this->failCallback())->then(function (ResponseInterface $response): void { + $this->assertTrue($response->hasHeader(RecordPlugin::HEADER_NAME), 'A header should be added'); + $this->assertSame(['foo'], $response->getHeader(RecordPlugin::HEADER_NAME)); + }); + + $this->assertSame($response, $this->recorder->replay('foo')); + } + + protected function getPluginClass(): string + { + return RecordPlugin::class; + } +} diff --git a/tests/Recorder/FilesystemRecorderTest.php b/tests/Recorder/FilesystemRecorderTest.php new file mode 100644 index 0000000..63a1061 --- /dev/null +++ b/tests/Recorder/FilesystemRecorderTest.php @@ -0,0 +1,57 @@ +recorder = new FilesystemRecorder($this->workspace, $this->filesystem); + } + + public function testReplay(): void + { + /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject $logger */ + $logger = $this->createMock(LoggerInterface::class); + + $logger->expects($this->once()) + ->method('debug') + ->with('[VCR-PLUGIN][FilesystemRecorder] Unable to replay {filename}', ['filename' => "$this->workspace/file_not_found.txt"]); + + $this->recorder->setLogger($logger); + + $this->assertNull($this->recorder->replay('file_not_found'), 'No response should be returned'); + } + + public function testRecord(): void + { + $original = new Response(200, ['X-Foo' => 'Bar'], 'The content'); + + $this->recorder->record('my_awesome_response', $original); + + $replayed = $this->recorder->replay('my_awesome_response'); + + $this->assertNotNull($replayed, 'Response should not be null'); + + $this->assertSame($original->getStatusCode(), $replayed->getStatusCode()); + $this->assertSame($original->getHeaders(), $replayed->getHeaders()); + $this->assertSame((string) $original->getBody(), (string) $replayed->getBody()); + } +} diff --git a/tests/Recorder/InMemoryRecorderTest.php b/tests/Recorder/InMemoryRecorderTest.php new file mode 100644 index 0000000..0a58ef8 --- /dev/null +++ b/tests/Recorder/InMemoryRecorderTest.php @@ -0,0 +1,43 @@ +recorder = new InMemoryRecorder(); + } + + public function testClear(): void + { + $this->recorder->record('foo', new Response()); + $this->recorder->clear(); + + $this->assertNull($this->recorder->replay('foo'), 'Should not return a response'); + } + + public function testReplay(): void + { + $response = new Response(); + + $this->recorder->record('foo', $response); + + $this->assertNull($this->recorder->replay('bar'), 'Should not return a response'); + $this->assertSame($response, $this->recorder->replay('foo')); + } +} diff --git a/tests/ReplayPluginTest.php b/tests/ReplayPluginTest.php new file mode 100644 index 0000000..f547baf --- /dev/null +++ b/tests/ReplayPluginTest.php @@ -0,0 +1,45 @@ +namingStrategy->method('name')->willReturn('foo'); + $next = function (): Promise { + return new FulfilledPromise(new Response(200, [], 'not replayed')); + }; + + $this->plugin->handleRequest($this->getRequest(), $next, $this->failCallback()) + ->then(function (ResponseInterface $response): void { + $this->assertFalse($response->hasHeader(ReplayPlugin::HEADER_NAME), 'Header should not be added'); + $this->assertSame('not replayed', (string) $response->getBody()); + }); + + $this->recorder->record('foo', new Response(200, [], 'Replayed')); + + $this->plugin->handleRequest($this->getRequest(), $this->failCallback(), $this->failCallback()) + ->then(function (ResponseInterface $response): void { + $this->assertTrue($response->hasHeader(ReplayPlugin::HEADER_NAME), 'A header should be added'); + $this->assertSame(['foo'], $response->getHeader(ReplayPlugin::HEADER_NAME)); + $this->assertSame('Replayed', (string) $response->getBody()); + }); + } + + protected function getPluginClass(): string + { + return ReplayPlugin::class; + } +} diff --git a/tests/Storage/FileStorageTest.php b/tests/Storage/FileStorageTest.php deleted file mode 100644 index bbab345..0000000 --- a/tests/Storage/FileStorageTest.php +++ /dev/null @@ -1,91 +0,0 @@ -root = vfsStream::setup(); - $this->storage = new FileStorage($this->root->url()); - } - - public function testWithNonExistingDir() - { - $dir = vfsStream::url('non_existing'); - - $this->expectException(Storage::class); - $this->expectExceptionMessage($dir.' does not exist'); - - new FileStorage($dir); - } - - public function testWithFileInsteadOfDir() - { - vfsStream::newFile('file')->at($this->root)->setContent('any'); - - $dir = $this->root->getChild('file')->url(); - - $this->expectException(Storage::class); - $this->expectExceptionMessage($dir.' is not a directory'); - - new FileStorage($dir); - } - - public function testWithNonWritableDir() - { - $this->root->chmod(0444); - - $dir = $this->root->url(); - - $this->expectException(Storage::class); - $this->expectExceptionMessage($dir.' is not writable'); - - new FileStorage($dir); - } - - public function testFetch() - { - $tape = $this->createTape('my_tape'); - vfsStream::newFile($tape->getName())->at($this->root)->setContent(serialize($tape)); - - $this->assertInstanceOf(Tape::class, $this->storage->fetch($tape->getName())); - } - - public function testFetchNonExisting() - { - $this->expectException(NotFound::class); - $this->storage->fetch('non_existing'); - } - - public function testStore() - { - $tape = $this->createTape('my_tape'); - - $this->storage->store($tape); - - $this->root->hasChild($tape->getName()); - } -} diff --git a/tests/Storage/InMemoryStorageTest.php b/tests/Storage/InMemoryStorageTest.php deleted file mode 100644 index e4cc6ed..0000000 --- a/tests/Storage/InMemoryStorageTest.php +++ /dev/null @@ -1,48 +0,0 @@ -storage = new InMemoryStorage(); - } - - public function testCreateWithExistingTapes() - { - $first = $this->createTape('first'); - $second = $this->createTape('second'); - - $storage = new InMemoryStorage([$first, $second]); - - $this->assertSame($first, $storage->fetch($first->getName())); - $this->assertSame($second, $storage->fetch($second->getName())); - } - - public function testStore() - { - $tape = $this->createTape('tape'); - - $this->storage->store($tape); - $this->assertSame($tape, $this->storage->fetch($tape->getName())); - } - - public function testFetchNonExisting() - { - $this->expectException(NotFound::class); - $this->storage->fetch('non_existing'); - } -} diff --git a/tests/TapeTest.php b/tests/TapeTest.php deleted file mode 100644 index fcadd8a..0000000 --- a/tests/TapeTest.php +++ /dev/null @@ -1,52 +0,0 @@ -request = new Request('GET', 'http://example.com'); - $this->track = $this->createTrack($this->request); - - $this->tape = new Tape('my_tape'); - } - - public function testCreateTape() - { - $this->assertInstanceOf(Tape::class, Tape::create('my_tape')); - } - - public function testGetName() - { - $this->assertEquals('my_tape', $this->tape->getName()); - } - - public function testAddTrack() - { - $this->tape->addTrack($this->track); - - $this->assertSame($this->track, $this->tape->findTrackByRequest($this->request)); - } - - public function testFindNonExistingTrack() - { - $this->expectException(NotFound::class); - $this->tape->findTrackByRequest(new Request('GET', 'http://nonexisting.tld')); - } -} diff --git a/tests/TrackTest.php b/tests/TrackTest.php deleted file mode 100644 index 2c4318c..0000000 --- a/tests/TrackTest.php +++ /dev/null @@ -1,80 +0,0 @@ -request = $this->createMock(RequestInterface::class); - $this->response = $this->createMock(ResponseInterface::class); - $this->exception = new \Exception(); - - $this->track = Track::create($this->request); - } - - public function testCreateTrack() - { - $this->assertSame($this->request, $this->track->getRequest()); - - $this->assertFalse($this->track->hasResponse()); - $this->assertNull($this->track->getResponse()); - - $this->assertFalse($this->track->hasException()); - $this->assertNull($this->track->getException()); - } - - public function testWithResponse() - { - $this->track->setResponse($this->response); - - $this->assertTrue($this->track->hasResponse()); - $this->assertSame($this->response, $this->track->getResponse()); - } - - public function testWithException() - { - $this->track->setException($this->exception); - - $this->assertTrue($this->track->hasException()); - $this->assertSame($this->exception, $this->track->getException()); - } - - public function testResponseBodyIsRewound() - { - $body = $this->createMock(StreamInterface::class); - $this->response->expects($this->once())->method('getBody')->willReturn($body); - - $this->track->setResponse($this->response); - - $body->expects($this->once())->method('seek')->with(0, SEEK_SET); - $this->track->getResponse(); - } -} diff --git a/tests/VcrClientTest.php b/tests/VcrClientTest.php deleted file mode 100644 index dea6817..0000000 --- a/tests/VcrClientTest.php +++ /dev/null @@ -1,59 +0,0 @@ -vcr = $this->createMock(Vcr::class); - $this->client = $this->createMock(ClientImplementation::class); - $this->vcrClient = new VcrClient($this->client, $this->vcr); - } - - public function testSendRequest() - { - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - - $this->client->expects($this->once())->method('sendRequest')->with($request)->willReturn($response); - - $this->assertSame($response, $this->vcrClient->sendRequest($request)); - } - - public function testSendAsyncRequest() - { - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $fulfilledPromise = new FulfilledPromise($response); - - $this->client->expects($this->once())->method('sendAsyncRequest')->with($request)->willReturn($fulfilledPromise); - - $promise = $this->vcrClient->sendAsyncRequest($request); - - $this->assertInstanceOf(FulfilledPromise::class, $promise); - $this->assertSame($response, $promise->wait()); - } -} diff --git a/tests/VcrPluginTest.php b/tests/VcrPluginTest.php deleted file mode 100644 index 394a1e4..0000000 --- a/tests/VcrPluginTest.php +++ /dev/null @@ -1,182 +0,0 @@ -request = $this->createMock(RequestInterface::class); - - $this->track = $this->getMockBuilder(Track::class)->disableOriginalConstructor()->getMock(); - $this->track - ->expects($this->any()) - ->method('getRequest') - ->willReturn($this->request); - - $this->tape = $this->getMockBuilder(Tape::class)->disableOriginalConstructor()->getMock(); - $this->tape - ->expects($this->any()) - ->method('findTrackByRequest') - ->with($this->request) - ->willReturn($this->track); - - $this->vcr = $this->getMockBuilder(Vcr::class)->disableOriginalConstructor()->getMock(); - - $this->plugin = new VcrPlugin($this->vcr); - } - - public function testReplayResponse() - { - $this->track->expects($this->any())->method('hasResponse')->willReturn(true); - $this->track - ->expects($this->any()) - ->method('getResponse') - ->willReturn($response = new Response(200)) - ; - - $this->vcr->expects($this->any())->method('isTurnedOn')->willReturn(true); - $this->vcr->expects($this->any())->method('hasTape')->willReturn(true); - $this->vcr->expects($this->any())->method('getTape')->willReturn($this->tape); - - $promise = $this->plugin->handleRequest($this->request, $this->fulfilledPromise($response), $this->rejectedPromise()); - - /** @var ResponseInterface $returnedResponse */ - $returnedResponse = $promise->wait(); - - $this->assertInstanceOf(ResponseInterface::class, $returnedResponse); - $this->assertTrue($returnedResponse->hasHeader(VcrPlugin::HEADER_VCR)); - $this->assertEquals(VcrPlugin::HEADER_VCR_REPLAY, $returnedResponse->getHeaderLine(VcrPlugin::HEADER_VCR)); - } - - public function testReplayException() - { - $this->track->expects($this->any())->method('hasResponse')->willReturn(false); - $this->track->expects($this->any())->method('hasException')->willReturn(true); - - $exception = new \Exception(); - - $this->track - ->expects($this->any()) - ->method('getException') - ->willReturn($exception) - ; - - $this->vcr->expects($this->any())->method('isTurnedOn')->willReturn(true); - $this->vcr->expects($this->any())->method('hasTape')->willReturn(true); - $this->vcr->expects($this->any())->method('getTape')->willReturn($this->tape); - - $promise = $this->plugin->handleRequest( - $this->request, - $this->fulfilledPromise($this->createMock(ResponseInterface::class)), - $this->rejectedPromise($exception) - ); - - $this->expectException(\Exception::class); - - $promise->wait(); - } - - public function testDoNothingIfTurnedOff() - { - $this->vcr->expects($this->any())->method('isTurnedOn')->willReturn(false); - - $promise = $this->plugin->handleRequest( - $this->request, - $this->fulfilledPromise($response = new Response(200)), - $this->rejectedPromise() - ); - - $this->assertSame($response, $promise->wait()); - } - - public function testDoNothingIfNotRecording() - { - $this->vcr->expects($this->any())->method('isTurnedOn')->willReturn(false); - $this->vcr->expects($this->any())->method('hasTape')->willReturn(false); - } - - public function testRecordRequestIfNotOnTape() - { - $this->vcr->expects($this->any())->method('isTurnedOn')->willReturn(true); - $this->vcr->expects($this->any())->method('hasTape')->willReturn(true); - $this->vcr->expects($this->any())->method('getTape')->willReturn($this->tape); - - $this->tape->expects($this->once())->method('findTrackByRequest')->willThrowException(new NotFound()); - - $this->tape->expects($this->once())->method('addTrack'); - - $this->plugin->handleRequest($this->request, $this->fulfilledPromise(), $this->rejectedPromise()); - } - - public function testRecordResponseIfNotOnTape() - { - $this->track->expects($this->any())->method('hasResponse')->willReturn(false); - $this->track->expects($this->any())->method('hasException')->willReturn(false); - - $this->vcr->expects($this->any())->method('isTurnedOn')->willReturn(true); - $this->vcr->expects($this->any())->method('hasTape')->willReturn(true); - $this->vcr->expects($this->any())->method('getTape')->willReturn($this->tape); - - $this->tape->expects($this->once())->method('findTrackByRequest')->willReturn($this->track); - - $this->tape->expects($this->once())->method('addTrack'); - - $this->plugin->handleRequest($this->request, $this->fulfilledPromise(), $this->rejectedPromise()); - } - - private function fulfilledPromise(ResponseInterface $response = null) - { - $response = $response ?: new Response(200); - - return function () use ($response) { - return new FulfilledPromise($response); - }; - } - - private function rejectedPromise(\Exception $e = null) - { - $e = $e ?: new \Exception(); - - return function () use ($e) { - if ($e instanceof \Exception) { - return new RejectedPromise($e); - } - }; - } -} diff --git a/tests/VcrTest.php b/tests/VcrTest.php deleted file mode 100644 index f0d3e65..0000000 --- a/tests/VcrTest.php +++ /dev/null @@ -1,129 +0,0 @@ -storage = $this->createMock(Storage::class); - $this->tape = $this->getMockBuilder(Tape::class)->disableOriginalConstructor()->getMock(); - - $this->vcr = Vcr::createWithStorage($this->storage); - } - - public function testTurnOnAndTurnOff() - { - $this->assertFalse($this->vcr->isTurnedOn()); - - $this->vcr->turnOn(); - $this->assertTrue($this->vcr->isTurnedOn()); - - $this->vcr->turnOff(); - $this->assertFalse($this->vcr->isTurnedOn()); - } - - public function testStartRecordingWithTurnedOffVcr() - { - $this->expectException(InvalidState::class); - - $this->vcr->startRecording(); - } - - public function testStartRecordingWithoutInsertedTape() - { - $this->vcr->turnOn(); - $this->expectException(InvalidState::class); - - $this->vcr->startRecording(); - } - - public function testStartRecording() - { - $this->vcr->turnOn(); - $this->vcr->insert($this->tape); - - $this->vcr->startRecording(); - $this->assertTrue($this->vcr->isRecording()); - } - - public function testStopRecording() - { - $this->vcr->turnOn(); - $this->vcr->insert($this->tape); - - $this->vcr->startRecording(); - $this->assertTrue($this->vcr->isRecording()); - - $this->vcr->stopRecording(); - $this->assertFalse($this->vcr->isRecording()); - } - - public function testGetTape() - { - $this->vcr->insert($this->tape); - - $this->assertSame($this->tape, $this->vcr->getTape()); - } - - public function testGetNonExistingTape() - { - $this->expectException(InvalidState::class); - $this->vcr->getTape(); - } - - public function testInsertTapeWithFilledDeck() - { - $this->vcr->insert($this->tape); - - $this->expectException(InvalidState::class); - $this->vcr->insert($this->tape); - } - - public function testInsertTapeWithGivenName() - { - $this->storage->expects($this->once())->method('fetch')->with('my_tape')->willReturn($this->tape); - - $this->vcr->insert('my_tape'); - - $this->assertSame($this->tape, $this->vcr->getTape()); - } - - public function testInsertNewTapeWithGivenName() - { - $this->storage->expects($this->once())->method('fetch')->with('my_tape')->willThrowException(new NotFound()); - - $this->vcr->insert('my_tape'); - - $this->assertInstanceOf(Tape::class, $this->vcr->getTape()); - } - - public function testEject() - { - $this->vcr->insert($this->tape); - $this->vcr->eject(); - - $this->assertFalse($this->vcr->hasTape()); - } -} diff --git a/tests/VcrTestCase.php b/tests/VcrTestCase.php deleted file mode 100644 index ae25b7a..0000000 --- a/tests/VcrTestCase.php +++ /dev/null @@ -1,56 +0,0 @@ -getMockBuilder(Tape::class) - ->enableArgumentCloning() - ->disableOriginalConstructor() - ->getMock(); - - $tape->expects($this->any())->method('getName')->willReturn($name); - - return $tape; - } - - /** - * @param RequestInterface $request - * @param ResponseInterface|null $response - * @param \Exception|null $exception - * - * @return Track|\PHPUnit_Framework_MockObject_MockObject - */ - protected function createTrack(RequestInterface $request, ResponseInterface $response = null, \Exception $exception = null) - { - $track = $this->getMockBuilder(Track::class)->disableOriginalConstructor()->getMock(); - - $track->expects($this->any())->method('getRequest')->willReturn($request); - - if ($response) { - $track->expects($this->any())->method('hasResponse')->willReturn(true); - $track->expects($this->any())->method('getResponse')->willReturn($response); - } else { - $track->expects($this->any())->method('hasResponse')->willReturn(false); - } - - if ($exception) { - $track->expects($this->any())->method('hasException')->willReturn(true); - $track->expects($this->any())->method('getException')->willReturn($exception); - } else { - $track->expects($this->any())->method('hasException')->willReturn(false); - } - - return $track; - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index fc5d63b..0000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,6 +0,0 @@ -addPsr4('Http\\Client\\Plugin\\Vcr\\', __DIR__); From fe64879f7a800849bd46dc65197969183c9f6e33 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Mon, 25 Mar 2019 21:06:01 +0000 Subject: [PATCH 2/8] Add doc, simplify options --- src/NamingStrategy/PathNamingStrategy.php | 53 ++++++++++--------- .../NamingStrategy/PathNamingStrategyTest.php | 32 ++++------- 2 files changed, 38 insertions(+), 47 deletions(-) diff --git a/src/NamingStrategy/PathNamingStrategy.php b/src/NamingStrategy/PathNamingStrategy.php index 0000126..8c5cd9d 100644 --- a/src/NamingStrategy/PathNamingStrategy.php +++ b/src/NamingStrategy/PathNamingStrategy.php @@ -9,7 +9,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; /** - * Will use the request path as filename. + * Will use the request attributes (hostname, path, headers & body) as filename. * * @author Gary PEGEOT */ @@ -20,6 +20,11 @@ class PathNamingStrategy implements NamingStrategyInterface */ private $options; + /** + * @param array $options available options: + * - hash_headers: the list of header names to hash, + * - hash_body_methods: Methods for which the body will be hashed (Default: PUT, POST, PATCH) + */ public function __construct(array $options = []) { $resolver = new OptionsResolver(); @@ -29,29 +34,18 @@ public function __construct(array $options = []) public function name(RequestInterface $request): string { - $parts = [$this->options['name_prefix']]; + $parts = [$request->getUri()->getHost()]; - if ($this->options['hostname_prefix']) { - $parts[] = $request->getUri()->getHost(); - } $method = strtoupper($request->getMethod()); $parts[] = $method; - $parts[] = str_replace(\DIRECTORY_SEPARATOR, '_', trim($request->getUri()->getPath(), '/')); + $parts[] = str_replace('/', '_', trim($request->getUri()->getPath(), '/')); + $parts[] = $this->getHeaderHash($request); if ($query = $request->getUri()->getQuery()) { $parts[] = $this->hash($query); } - if ($this->options['use_headers']) { - $headers = ''; - foreach ($request->getHeaders() as $header => $values) { - $headers .= "$header:".implode(',', $values); - } - - $parts[] = $this->hash($headers); - } - if (\in_array($method, $this->options['hash_body_methods'], true)) { $parts[] = $this->hash((string) $request->getBody()); } @@ -59,27 +53,38 @@ public function name(RequestInterface $request): string return implode('_', array_filter($parts)); } - public function configureOptions(OptionsResolver $resolver): void + private function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'hostname_prefix' => false, - 'name_prefix' => '', - 'use_headers' => false, + 'hash_headers' => [], 'hash_body_methods' => ['PUT', 'POST', 'PATCH'], ]); - $resolver->setAllowedTypes('hostname_prefix', 'bool'); - $resolver->setAllowedTypes('name_prefix', ['null', 'string']); - $resolver->setAllowedTypes('use_headers', 'bool'); + $resolver->setAllowedTypes('hash_headers', 'string[]'); $resolver->setAllowedTypes('hash_body_methods', 'string[]'); - $resolver->setNormalizer('hash_body_methods', function (Options $options, $value) { + $normalizer = function (Options $options, $value) { return \is_array($value) ? array_map('strtoupper', $value) : $value; - }); + }; + $resolver->setNormalizer('hash_headers', $normalizer); + $resolver->setNormalizer('hash_body_methods', $normalizer); } private function hash(string $value): string { return substr(sha1($value), 0, 5); } + + private function getHeaderHash(RequestInterface $request): ?string + { + $headers = []; + + foreach ($this->options['hash_headers'] as $name) { + if ($request->hasHeader($name)) { + $headers[] = "$name:".implode(',', $request->getHeader($name)); + } + } + + return empty($headers) ? null : $this->hash(implode(';', $headers)); + } } diff --git a/tests/NamingStrategy/PathNamingStrategyTest.php b/tests/NamingStrategy/PathNamingStrategyTest.php index a914e0a..295fe5b 100644 --- a/tests/NamingStrategy/PathNamingStrategyTest.php +++ b/tests/NamingStrategy/PathNamingStrategyTest.php @@ -8,7 +8,6 @@ use Http\Client\Plugin\Vcr\NamingStrategy\PathNamingStrategy; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; -use Symfony\Component\OptionsResolver\OptionsResolver; /** * @internal @@ -25,22 +24,6 @@ public function testName(string $expected, RequestInterface $request, array $opt $this->assertSame($expected, $strategy->name($request)); } - public function testConfigureOptions(): void - { - $resolver = new OptionsResolver(); - - $expected = [ - 'hostname_prefix' => false, - 'name_prefix' => '', - 'use_headers' => false, - 'hash_body_methods' => ['PUT'], - ]; - - (new PathNamingStrategy())->configureOptions($resolver); - - $this->assertSame($expected, $resolver->resolve(['hash_body_methods' => ['put']])); - } - public function provideRequests(): \Generator { yield 'Simple GET request' => ['GET_my-path_my-sub-path', $this->getRequest('/my-path/my-sub-path')]; @@ -52,19 +35,22 @@ public function provideRequests(): \Generator yield 'GET request with hostname' => [ 'example.org_GET_my-path', $this->getRequest('https://example.org/my-path'), - ['hostname_prefix' => true], ]; - yield 'Custom prefix' => ['foo_GET_my-path', $this->getRequest('/my-path'), ['name_prefix' => 'foo']]; - - yield 'Header hash' => ['GET_my-path_76a1f', $this->getRequest('/my-path'), ['use_headers' => true]]; + yield 'Header hash' => ['GET_my-path_4727a', $this->getRequest('/my-path'), ['hash_headers' => ['X-Foo']]]; yield 'Body hash' => ['POST_my-action_d3b09', $this->getRequest('/my-action', 'POST', '{"hello": "world"}')]; + yield 'Method excluded' => [ + 'POST_my-action', + $this->getRequest('/my-action', 'POST', '{"hello": "world"}'), + ['hash_body_methods' => []], + ]; + yield 'Full package' => [ - 'POST_my-action_1a3b6_76a1f_d3b09', + 'POST_my-action_4727a_1a3b6_d3b09', $this->getRequest('/my-action?page=1', 'POST', '{"hello": "world"}'), - ['use_headers' => true], + ['hash_headers' => ['X-Foo']], ]; } From 5cd124324ef00536687566ccf467064059c543ab Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Mon, 25 Mar 2019 21:09:30 +0000 Subject: [PATCH 3/8] Record unless replayed --- src/RecordPlugin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RecordPlugin.php b/src/RecordPlugin.php index 2ed3885..b212750 100644 --- a/src/RecordPlugin.php +++ b/src/RecordPlugin.php @@ -39,7 +39,7 @@ public function handleRequest(RequestInterface $request, callable $next, callabl $name = $this->namingStrategy->name($request); return $next($request)->then(function (ResponseInterface $response) use ($name) { - if ($response->getStatusCode() < 300 && !$response->hasHeader(ReplayPlugin::HEADER_NAME)) { + if (!$response->hasHeader(ReplayPlugin::HEADER_NAME)) { $this->recorder->record($name, $response); $response = $response->withAddedHeader(static::HEADER_NAME, $name); } From 42412b7433f035dd25f3adf45cee5809f60383c9 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Mon, 25 Mar 2019 21:16:21 +0000 Subject: [PATCH 4/8] Update class doc --- src/NamingStrategy/NamingStrategyInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NamingStrategy/NamingStrategyInterface.php b/src/NamingStrategy/NamingStrategyInterface.php index 7630ac7..c05b9d0 100644 --- a/src/NamingStrategy/NamingStrategyInterface.php +++ b/src/NamingStrategy/NamingStrategyInterface.php @@ -7,7 +7,7 @@ use Psr\Http\Message\RequestInterface; /** - * In charge of giving a deterministic name to a request. + * Provides a deterministic and unique identifier for a request. The identifier must be safe to use with a filesystem. * * @author Gary PEGEOT */ From 3f0af84dd360198dc740f50b3f04b7040169f94c Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Mon, 25 Mar 2019 21:44:21 +0000 Subject: [PATCH 5/8] Fix PHPUnit bridge version --- composer.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 73a0e5b..522d9a0 100644 --- a/composer.json +++ b/composer.json @@ -6,9 +6,6 @@ "homepage": "http://httplug.io", "authors": [ { - "name": "Jérôme Gamez", - "email": "jerome@gamez.name" - },{ "name": "Gary PEGEOT", "email": "garypegeot@gmail.com" } @@ -22,7 +19,7 @@ "symfony/options-resolver": "^3.4|^4.0" }, "require-dev": { - "symfony/phpunit-bridge": "*", + "symfony/phpunit-bridge": ">= 4.2", "friendsofphp/php-cs-fixer": "^2.14" }, "autoload": { From 9939effc00033cded22b561361ca467766d70ac7 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Mon, 25 Mar 2019 21:54:44 +0000 Subject: [PATCH 6/8] Test that file is actually created --- tests/Recorder/FilesystemRecorderTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Recorder/FilesystemRecorderTest.php b/tests/Recorder/FilesystemRecorderTest.php index 63a1061..107b638 100644 --- a/tests/Recorder/FilesystemRecorderTest.php +++ b/tests/Recorder/FilesystemRecorderTest.php @@ -6,6 +6,7 @@ use GuzzleHttp\Psr7\Response; use Http\Client\Plugin\Vcr\Recorder\FilesystemRecorder; +use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use Symfony\Component\Filesystem\Tests\FilesystemTestCase; @@ -28,7 +29,7 @@ protected function setUp(): void public function testReplay(): void { - /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject $logger */ + /** @var LoggerInterface|MockObject $logger */ $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->once()) @@ -45,8 +46,9 @@ public function testRecord(): void $original = new Response(200, ['X-Foo' => 'Bar'], 'The content'); $this->recorder->record('my_awesome_response', $original); + $this->assertFileExists(sprintf('%s%smy_awesome_response.txt', $this->workspace, \DIRECTORY_SEPARATOR)); - $replayed = $this->recorder->replay('my_awesome_response'); + $replayed = (new FilesystemRecorder($this->workspace))->replay('my_awesome_response'); $this->assertNotNull($replayed, 'Response should not be null'); From b4fc3116092e2583dcd6fe65835aff3193d75ae0 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Wed, 27 Mar 2019 22:03:27 +0000 Subject: [PATCH 7/8] Update class doc --- src/Recorder/FilesystemRecorder.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Recorder/FilesystemRecorder.php b/src/Recorder/FilesystemRecorder.php index 74ecf2e..1105c1c 100644 --- a/src/Recorder/FilesystemRecorder.php +++ b/src/Recorder/FilesystemRecorder.php @@ -12,7 +12,8 @@ use Symfony\Component\Filesystem\Filesystem; /** - * Stores responses in a defined directory, preferably accessible to your VCS. + * Stores responses using the `guzzlehttp/psr7` library to serialize and deserialize the response. + * Target directory should be part of your VCS. * * @author Gary PEGEOT */ From 76fab383e7b0f4c4eae06594139bde7389971b92 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Wed, 27 Mar 2019 22:26:02 +0000 Subject: [PATCH 8/8] Optionnaly throw on recorder miss --- src/ReplayPlugin.php | 27 ++++++++++++++++++++++++++- tests/ReplayPluginTest.php | 11 +++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/ReplayPlugin.php b/src/ReplayPlugin.php index 3d9f3c6..0c90577 100644 --- a/src/ReplayPlugin.php +++ b/src/ReplayPlugin.php @@ -5,6 +5,7 @@ namespace Http\Client\Plugin\Vcr; use Http\Client\Common\Plugin; +use Http\Client\Exception\RequestException; use Http\Client\Plugin\Vcr\NamingStrategy\NamingStrategyInterface; use Http\Client\Plugin\Vcr\Recorder\PlayerInterface; use Http\Promise\FulfilledPromise; @@ -25,10 +26,18 @@ final class ReplayPlugin implements Plugin */ private $player; - public function __construct(NamingStrategyInterface $namingStrategy, PlayerInterface $player) + /** + * Throw an exception if not able to replay a request. + * + * @var bool + */ + private $throw; + + public function __construct(NamingStrategyInterface $namingStrategy, PlayerInterface $player, bool $throw = true) { $this->namingStrategy = $namingStrategy; $this->player = $player; + $this->throw = $throw; } /** @@ -42,6 +51,22 @@ public function handleRequest(RequestInterface $request, callable $next, callabl return new FulfilledPromise($response->withAddedHeader(static::HEADER_NAME, $name)); } + if ($this->throw) { + throw new RequestException("Unable to find a response to replay request \"$name\".", $request); + } + return $next($request); } + + /** + * Whenever the plugin should throw an exception when not able to replay a request. + * + * @return $this + */ + public function throwOnNotFound(bool $throw) + { + $this->throw = $throw; + + return $this; + } } diff --git a/tests/ReplayPluginTest.php b/tests/ReplayPluginTest.php index f547baf..cb58895 100644 --- a/tests/ReplayPluginTest.php +++ b/tests/ReplayPluginTest.php @@ -5,6 +5,7 @@ namespace Http\Client\Plugin\Vcr\Tests; use GuzzleHttp\Psr7\Response; +use Http\Client\Exception\RequestException; use Http\Client\Plugin\Vcr\ReplayPlugin; use Http\Promise\FulfilledPromise; use Http\Promise\Promise; @@ -17,6 +18,7 @@ class ReplayPluginTest extends AbstractPluginTestCase { public function testHandleRequest(): void { + $this->plugin->throwOnNotFound(false); $this->namingStrategy->method('name')->willReturn('foo'); $next = function (): Promise { return new FulfilledPromise(new Response(200, [], 'not replayed')); @@ -38,6 +40,15 @@ public function testHandleRequest(): void }); } + public function testHandleRequestThrow(): void + { + $this->expectException(RequestException::class); + $this->expectExceptionMessage('Unable to find a response to replay request "foo".'); + $this->namingStrategy->method('name')->willReturn('foo'); + + $this->plugin->handleRequest($this->getRequest(), $this->failCallback(), $this->failCallback()); + } + protected function getPluginClass(): string { return ReplayPlugin::class;