Skip to content

Commit 7094e66

Browse files
GaryPEGEOTdbu
authored andcommitted
[Plugin] Add VCR Record & Replay plugins (#3)
* [Plugin] Add VCR Record & Replay plugins
1 parent ae671f6 commit 7094e66

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+725
-1378
lines changed

.php_cs

Lines changed: 0 additions & 13 deletions
This file was deleted.

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ $ composer require --dev php-http/vcr-plugin
2323

2424
## Usage
2525

26+
```php
27+
<?php
28+
29+
use Http\Client\Plugin\Vcr\NamingStrategy\PathNamingStrategy;
30+
use Http\Client\Plugin\Vcr\Recorder\FilesystemRecorder;
31+
use Http\Client\Plugin\Vcr\RecordPlugin;
32+
use Http\Client\Plugin\Vcr\ReplayPlugin;
33+
34+
$namingStrategy = new PathNamingStrategy();
35+
$recorder = new FilesystemRecorder('some/dir/in/vcs'); // You can use InMemoryRecorder as well
36+
37+
// To record responses:
38+
$record = new RecordPlugin($namingStrategy, $recorder);
39+
40+
// To replay responses:
41+
$replay = new ReplayPlugin($namingStrategy, $recorder);
42+
```
2643

2744
## Testing
2845

composer.json

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,36 @@
66
"homepage": "http://httplug.io",
77
"authors": [
88
{
9-
"name": "Jérôme Gamez",
10-
"email": "jerome@gamez.name"
9+
"name": "Gary PEGEOT",
10+
"email": "garypegeot@gmail.com"
1111
}
1212
],
1313
"require": {
14-
"php": "^5.6|^7.0",
15-
"php-http/plugins": "^1.0",
16-
"guzzlehttp/psr7": "^1.2"
14+
"php": "^7.1",
15+
"guzzlehttp/psr7": "^1.4",
16+
"php-http/client-common": "^2.0",
17+
"psr/log": "^1.0",
18+
"symfony/filesystem": "^3.4|^4.0",
19+
"symfony/options-resolver": "^3.4|^4.0"
1720
},
1821
"require-dev": {
19-
"phpunit/phpunit": "^5.2",
20-
"mikey179/vfsStream": "^1.6"
22+
"symfony/phpunit-bridge": ">= 4.2",
23+
"friendsofphp/php-cs-fixer": "^2.14"
2124
},
2225
"autoload": {
2326
"psr-4": {
2427
"Http\\Client\\Plugin\\Vcr\\": "src"
2528
}
2629
},
27-
"scripts": {
28-
"test": "vendor/bin/phpunit",
29-
"test-ci": "vendor/bin/phpunit --coverage-text --coverage-clover=build/coverage.xml"
30-
},
31-
"config": {
32-
"platform": {
33-
"php": "5.6"
30+
"autoload-dev": {
31+
"psr-4": {
32+
"Http\\Client\\Plugin\\Vcr\\Tests\\": "tests"
3433
}
3534
},
35+
"scripts": {
36+
"test": "vendor/bin/simple-phpunit",
37+
"test-ci": "vendor/bin/simple-phpunit --coverage-text --coverage-clover=build/coverage.xml"
38+
},
3639
"extra": {
3740
"branch-alias": {
3841
"dev-master": "1.0-dev"

phpunit.xml.dist

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<phpunit
33
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4-
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.2/phpunit.xsd"
5-
bootstrap="tests/bootstrap.php"
6-
forceCoversAnnotation="true"
4+
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.5/phpunit.xsd"
5+
bootstrap="vendor/autoload.php"
76
colors="true">
87
<testsuites>
98
<testsuite name="VCR Plugin Test Suite">
@@ -13,7 +12,7 @@
1312

1413
<filter>
1514
<whitelist>
16-
<directory suffix=".php">.</directory>
15+
<directory suffix=".php">src</directory>
1716
<exclude>
1817
<directory>./tests</directory>
1918
<directory>./vendor</directory>

src/Exception/CannotBeReplayed.php

Lines changed: 0 additions & 7 deletions
This file was deleted.

src/Exception/InvalidState.php

Lines changed: 0 additions & 7 deletions
This file was deleted.

src/Exception/NotFound.php

Lines changed: 0 additions & 7 deletions
This file was deleted.

src/Exception/Storage.php

Lines changed: 0 additions & 7 deletions
This file was deleted.

src/Exception/VcrException.php

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Http\Client\Plugin\Vcr\NamingStrategy;
6+
7+
use Psr\Http\Message\RequestInterface;
8+
9+
/**
10+
* Provides a deterministic and unique identifier for a request. The identifier must be safe to use with a filesystem.
11+
*
12+
* @author Gary PEGEOT <garypegeot@gmail.com>
13+
*/
14+
interface NamingStrategyInterface
15+
{
16+
public function name(RequestInterface $request): string;
17+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Http\Client\Plugin\Vcr\NamingStrategy;
6+
7+
use Psr\Http\Message\RequestInterface;
8+
use Symfony\Component\OptionsResolver\Options;
9+
use Symfony\Component\OptionsResolver\OptionsResolver;
10+
11+
/**
12+
* Will use the request attributes (hostname, path, headers & body) as filename.
13+
*
14+
* @author Gary PEGEOT <garypegeot@gmail.com>
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+
}

src/RecordPlugin.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Http\Client\Plugin\Vcr;
6+
7+
use Http\Client\Common\Plugin;
8+
use Http\Client\Plugin\Vcr\NamingStrategy\NamingStrategyInterface;
9+
use Http\Client\Plugin\Vcr\Recorder\RecorderInterface;
10+
use Http\Promise\Promise;
11+
use Psr\Http\Message\RequestInterface;
12+
use Psr\Http\Message\ResponseInterface;
13+
14+
final class RecordPlugin implements Plugin
15+
{
16+
const HEADER_NAME = 'X-VCR-RECORD';
17+
18+
/**
19+
* @var NamingStrategyInterface
20+
*/
21+
private $namingStrategy;
22+
23+
/**
24+
* @var RecorderInterface
25+
*/
26+
private $recorder;
27+
28+
public function __construct(NamingStrategyInterface $namingStrategy, RecorderInterface $recorder)
29+
{
30+
$this->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+
}

src/Recorder/FilesystemRecorder.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Http\Client\Plugin\Vcr\Recorder;
6+
7+
use GuzzleHttp\Psr7;
8+
use Psr\Http\Message\ResponseInterface;
9+
use Psr\Log\LoggerAwareInterface;
10+
use Psr\Log\LoggerAwareTrait;
11+
use Symfony\Component\Filesystem\Exception\IOException;
12+
use Symfony\Component\Filesystem\Filesystem;
13+
14+
/**
15+
* Stores responses using the `guzzlehttp/psr7` library to serialize and deserialize the response.
16+
* Target directory should be part of your VCS.
17+
*
18+
* @author Gary PEGEOT <garypegeot@gmail.com>
19+
*/
20+
final class FilesystemRecorder implements RecorderInterface, PlayerInterface, LoggerAwareInterface
21+
{
22+
use LoggerAwareTrait;
23+
24+
/**
25+
* @var string
26+
*/
27+
private $directory;
28+
29+
/**
30+
* @var Filesystem
31+
*/
32+
private $filesystem;
33+
34+
public function __construct(string $directory, ?Filesystem $filesystem = null)
35+
{
36+
$this->filesystem = $filesystem ?? new Filesystem();
37+
38+
if (!$this->filesystem->exists($directory)) {
39+
try {
40+
$this->filesystem->mkdir($directory);
41+
} catch (IOException $e) {
42+
throw new \InvalidArgumentException("Unable to create directory \"$directory\"/: {$e->getMessage()}", $e->getCode(), $e);
43+
}
44+
}
45+
46+
$this->directory = realpath($directory).\DIRECTORY_SEPARATOR;
47+
}
48+
49+
public function replay(string $name): ?ResponseInterface
50+
{
51+
$filename = "{$this->directory}$name.txt";
52+
$context = compact('filename');
53+
54+
if (!$this->filesystem->exists($filename)) {
55+
$this->log('Unable to replay {filename}', $context);
56+
57+
return null;
58+
}
59+
60+
$this->log('Response replayed from {filename}', $context);
61+
62+
return Psr7\parse_response(file_get_contents($filename));
63+
}
64+
65+
public function record(string $name, ResponseInterface $response): void
66+
{
67+
$filename = "{$this->directory}$name.txt";
68+
$context = compact('name', 'filename');
69+
70+
$this->filesystem->dumpFile($filename, Psr7\str($response));
71+
72+
$this->log('Response for {name} stored into {filename}', $context);
73+
}
74+
75+
private function log(string $message, array $context = []): void
76+
{
77+
if ($this->logger) {
78+
$this->logger->debug("[VCR-PLUGIN][FilesystemRecorder] $message", $context);
79+
}
80+
}
81+
}

0 commit comments

Comments
 (0)