├── phpstan.neon.dist ├── src ├── Recorder │ ├── PlayerInterface.php │ ├── RecorderInterface.php │ ├── InMemoryRecorder.php │ └── FilesystemRecorder.php ├── NamingStrategy │ ├── NamingStrategyInterface.php │ └── PathNamingStrategy.php ├── RecordPlugin.php └── ReplayPlugin.php ├── CHANGELOG.md ├── LICENSE ├── composer.json └── README.md /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src/ 5 | ignoreErrors: 6 | # The logger is initialized to NullLogger in the constructor of the FilesystemRecorder, but phpstan does not see that 7 | - message: '#Cannot call method debug\(\) on Psr\\Log\\LoggerInterface\|null#' 8 | path: src/Recorder/FilesystemRecorder.php 9 | -------------------------------------------------------------------------------- /src/Recorder/PlayerInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | interface PlayerInterface 15 | { 16 | public function replay(string $name): ?ResponseInterface; 17 | } 18 | -------------------------------------------------------------------------------- /src/Recorder/RecorderInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | interface RecorderInterface 15 | { 16 | public function record(string $name, ResponseInterface $response): void; 17 | } 18 | -------------------------------------------------------------------------------- /src/NamingStrategy/NamingStrategyInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | interface NamingStrategyInterface 15 | { 16 | public function name(RequestInterface $request): string; 17 | } 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.2.4 4 | 5 | - Support Symfony 8 6 | - Test with PHP 8.5 7 | 8 | ## 1.2.3 - 2024-01-04 9 | 10 | - Support Symfony 7 11 | 12 | ## 1.2.2 - 2023-02-17 13 | 14 | - Allow installation with Psr\Log 2 and 3. 15 | 16 | ## 1.2.1 - 2022-01-12 17 | 18 | - Support Symfony 6 19 | 20 | ## 1.2.0 - 2020-11-30 21 | 22 | - Support PHP 8 23 | 24 | ## 1.1.0 - 2020-11-21 25 | 26 | - Implement a replacement filter to cleanup private data in responses 27 | 28 | ## 1.0.0 - 2019-11-19 29 | 30 | - Initial release 31 | -------------------------------------------------------------------------------- /src/Recorder/InMemoryRecorder.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class InMemoryRecorder implements PlayerInterface, RecorderInterface 15 | { 16 | /** 17 | * @var ResponseInterface[] 18 | */ 19 | private $responses = []; 20 | 21 | public function replay(string $name): ?ResponseInterface 22 | { 23 | return $this->responses[$name] ?? null; 24 | } 25 | 26 | public function record(string $name, ResponseInterface $response): void 27 | { 28 | $this->responses[$name] = $response; 29 | } 30 | 31 | public function clear(): void 32 | { 33 | $this->responses = []; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) PHP HTTP Team 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-http/vcr-plugin", 3 | "description": "Record your test suite's HTTP interactions and replay them during future test runs.", 4 | "license": "MIT", 5 | "keywords": ["http", "vcr", "plugin", "psr7"], 6 | "homepage": "http://httplug.io", 7 | "authors": [ 8 | { 9 | "name": "Gary PEGEOT", 10 | "email": "garypegeot@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.2 || ^8.0", 15 | "guzzlehttp/psr7": "^1.7 || ^2.0", 16 | "php-http/client-common": "^2.0", 17 | "psr/log": "^1.1 || ^2.0 || ^3.0", 18 | "symfony/filesystem": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", 19 | "symfony/options-resolver": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" 20 | }, 21 | "require-dev": { 22 | "symfony/phpunit-bridge": "^4.2 || ^5.0 || ^6.0 || ^7.0 || ^8.0", 23 | "phpstan/phpstan": "^1.0" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Http\\Client\\Plugin\\Vcr\\": "src" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Http\\Client\\Plugin\\Vcr\\Tests\\": "tests" 33 | } 34 | }, 35 | "scripts": { 36 | "test": "vendor/bin/simple-phpunit", 37 | "test-static": "vendor/bin/phpstan" 38 | }, 39 | "prefer-stable": true, 40 | "minimum-stability": "dev" 41 | } 42 | -------------------------------------------------------------------------------- /src/RecordPlugin.php: -------------------------------------------------------------------------------- 1 | namingStrategy = $namingStrategy; 31 | $this->recorder = $recorder; 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 38 | { 39 | $name = $this->namingStrategy->name($request); 40 | 41 | return $next($request)->then(function (ResponseInterface $response) use ($name) { 42 | if (!$response->hasHeader(ReplayPlugin::HEADER_NAME)) { 43 | $this->recorder->record($name, $response); 44 | $response = $response->withAddedHeader(static::HEADER_NAME, $name); 45 | } 46 | 47 | return $response; 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ReplayPlugin.php: -------------------------------------------------------------------------------- 1 | namingStrategy = $namingStrategy; 39 | $this->player = $player; 40 | $this->throw = $throw; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 47 | { 48 | $name = $this->namingStrategy->name($request); 49 | 50 | if ($response = $this->player->replay($name)) { 51 | return new FulfilledPromise($response->withAddedHeader(static::HEADER_NAME, $name)); 52 | } 53 | 54 | if ($this->throw) { 55 | throw new RequestException("Unable to find a response to replay request \"$name\".", $request); 56 | } 57 | 58 | return $next($request); 59 | } 60 | 61 | /** 62 | * Whenever the plugin should throw an exception when not able to replay a request. 63 | * 64 | * @return $this 65 | */ 66 | public function throwOnNotFound(bool $throw) 67 | { 68 | $this->throw = $throw; 69 | 70 | return $this; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VCR Plugin 2 | 3 | [![Latest Version](https://img.shields.io/github/release/php-http/vcr-plugin.svg?style=flat-square)](https://github.com/php-http/vcr-plugin/releases) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) 5 | [![Build Status](https://img.shields.io/travis/php-http/vcr-plugin.svg?style=flat-square)](https://travis-ci.org/php-http/vcr-plugin) 6 | [![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/php-http/vcr-plugin.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/vcr-plugin) 7 | [![Quality Score](https://img.shields.io/scrutinizer/g/php-http/vcr-plugin.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/vcr-plugin) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/php-http/vcr-plugin.svg?style=flat-square)](https://packagist.org/packages/php-http/vcr-plugin) 9 | 10 | **Record your test suite's HTTP interactions and replay them during future test runs.** 11 | 12 | ## Install 13 | 14 | Via Composer 15 | 16 | ``` bash 17 | $ composer require --dev php-http/vcr-plugin 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```php 23 | 15 | */ 16 | class PathNamingStrategy implements NamingStrategyInterface 17 | { 18 | /** 19 | * @var array 20 | */ 21 | private $options; 22 | 23 | /** 24 | * @param array $options available options: 25 | * - hash_headers: the list of header names to hash, 26 | * - hash_body_methods: Methods for which the body will be hashed (Default: PUT, POST, PATCH) 27 | */ 28 | public function __construct(array $options = []) 29 | { 30 | $resolver = new OptionsResolver(); 31 | $this->configureOptions($resolver); 32 | $this->options = $resolver->resolve($options); 33 | } 34 | 35 | public function name(RequestInterface $request): string 36 | { 37 | $parts = [$request->getUri()->getHost()]; 38 | 39 | $method = strtoupper($request->getMethod()); 40 | 41 | $parts[] = $method; 42 | $parts[] = str_replace('/', '_', trim($request->getUri()->getPath(), '/')); 43 | $parts[] = $this->getHeaderHash($request); 44 | 45 | if ($query = $request->getUri()->getQuery()) { 46 | $parts[] = $this->hash($query); 47 | } 48 | 49 | if (\in_array($method, $this->options['hash_body_methods'], true)) { 50 | $parts[] = $this->hash((string) $request->getBody()); 51 | } 52 | 53 | return implode('_', array_filter($parts)); 54 | } 55 | 56 | private function configureOptions(OptionsResolver $resolver): void 57 | { 58 | $resolver->setDefaults([ 59 | 'hash_headers' => [], 60 | 'hash_body_methods' => ['PUT', 'POST', 'PATCH'], 61 | ]); 62 | 63 | $resolver->setAllowedTypes('hash_headers', 'string[]'); 64 | $resolver->setAllowedTypes('hash_body_methods', 'string[]'); 65 | 66 | $normalizer = function (Options $options, $value) { 67 | return \is_array($value) ? array_map('strtoupper', $value) : $value; 68 | }; 69 | $resolver->setNormalizer('hash_headers', $normalizer); 70 | $resolver->setNormalizer('hash_body_methods', $normalizer); 71 | } 72 | 73 | private function hash(string $value): string 74 | { 75 | return substr(sha1($value), 0, 5); 76 | } 77 | 78 | private function getHeaderHash(RequestInterface $request): ?string 79 | { 80 | $headers = []; 81 | 82 | foreach ($this->options['hash_headers'] as $name) { 83 | if ($request->hasHeader($name)) { 84 | $headers[] = "$name:".implode(',', $request->getHeader($name)); 85 | } 86 | } 87 | 88 | return empty($headers) ? null : $this->hash(implode(';', $headers)); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Recorder/FilesystemRecorder.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class FilesystemRecorder implements RecorderInterface, PlayerInterface, LoggerAwareInterface 22 | { 23 | use LoggerAwareTrait; 24 | 25 | /** 26 | * @var string 27 | */ 28 | private $directory; 29 | 30 | /** 31 | * @var Filesystem 32 | */ 33 | private $filesystem; 34 | 35 | /** 36 | * @var array 37 | */ 38 | private $filters; 39 | 40 | /** 41 | * @param array $filters 42 | */ 43 | public function __construct(string $directory, ?Filesystem $filesystem = null, array $filters = []) 44 | { 45 | $this->filesystem = $filesystem ?? new Filesystem(); 46 | 47 | if (!$this->filesystem->exists($directory)) { 48 | try { 49 | $this->filesystem->mkdir($directory); 50 | } catch (IOException $e) { 51 | throw new \InvalidArgumentException("Unable to create directory \"$directory\"/: {$e->getMessage()}", $e->getCode(), $e); 52 | } 53 | } 54 | 55 | $this->directory = realpath($directory).\DIRECTORY_SEPARATOR; 56 | $this->filters = $filters; 57 | $this->logger = new NullLogger(); 58 | } 59 | 60 | public function replay(string $name): ?ResponseInterface 61 | { 62 | $filename = "{$this->directory}$name.txt"; 63 | $context = compact('filename'); 64 | 65 | if (!$this->filesystem->exists($filename)) { 66 | $this->log('Unable to replay {filename}', $context); 67 | 68 | return null; 69 | } 70 | 71 | $this->log('Response replayed from {filename}', $context); 72 | 73 | if (false === $content = file_get_contents($filename)) { 74 | throw new \RuntimeException(sprintf('Unable to read "%s" file content', $filename)); 75 | } 76 | 77 | return Message::parseResponse($content); 78 | } 79 | 80 | public function record(string $name, ResponseInterface $response): void 81 | { 82 | $filename = "{$this->directory}$name.txt"; 83 | $context = compact('name', 'filename'); 84 | 85 | if (null === $content = preg_replace(array_keys($this->filters), array_values($this->filters), Message::toString($response))) { 86 | throw new \RuntimeException('Some of the provided response filters are invalid.'); 87 | } 88 | 89 | $this->filesystem->dumpFile($filename, $content); 90 | 91 | $this->log('Response for {name} stored into {filename}', $context); 92 | } 93 | 94 | /** 95 | * @param array $context 96 | */ 97 | private function log(string $message, array $context = []): void 98 | { 99 | $this->logger->debug("[VCR-PLUGIN][FilesystemRecorder] $message", $context); 100 | } 101 | } 102 | --------------------------------------------------------------------------------