Skip to content

[Plugin] Add VCR Record & Replay plugins #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 30, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions .php_cs

This file was deleted.

17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ $ composer require --dev php-http/vcr-plugin

## Usage

```php
<?php

use Http\Client\Plugin\Vcr\NamingStrategy\PathNamingStrategy;
use Http\Client\Plugin\Vcr\Recorder\FilesystemRecorder;
use Http\Client\Plugin\Vcr\RecordPlugin;
use Http\Client\Plugin\Vcr\ReplayPlugin;

$namingStrategy = new PathNamingStrategy();
$recorder = new FilesystemRecorder('some/dir/in/vcs'); // You can use InMemoryRecorder as well

// To record responses:
$record = new RecordPlugin($namingStrategy, $recorder);

// To replay responses:
$replay = new ReplayPlugin($namingStrategy, $recorder);
```

## Testing

Expand Down
31 changes: 17 additions & 14 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,36 @@
"homepage": "http://httplug.io",
"authors": [
{
"name": "Jérôme Gamez",
"email": "jerome@gamez.name"
"name": "Gary PEGEOT",
"email": "garypegeot@gmail.com"
}
],
"require": {
"php": "^5.6|^7.0",
"php-http/plugins": "^1.0",
"guzzlehttp/psr7": "^1.2"
"php": "^7.1",
"guzzlehttp/psr7": "^1.4",
"php-http/client-common": "^2.0",
"psr/log": "^1.0",
"symfony/filesystem": "^3.4|^4.0",
"symfony/options-resolver": "^3.4|^4.0"
},
"require-dev": {
"phpunit/phpunit": "^5.2",
"mikey179/vfsStream": "^1.6"
"symfony/phpunit-bridge": ">= 4.2",
"friendsofphp/php-cs-fixer": "^2.14"
},
"autoload": {
"psr-4": {
"Http\\Client\\Plugin\\Vcr\\": "src"
}
},
"scripts": {
"test": "vendor/bin/phpunit",
"test-ci": "vendor/bin/phpunit --coverage-text --coverage-clover=build/coverage.xml"
},
"config": {
"platform": {
"php": "5.6"
"autoload-dev": {
"psr-4": {
"Http\\Client\\Plugin\\Vcr\\Tests\\": "tests"
}
},
"scripts": {
"test": "vendor/bin/simple-phpunit",
"test-ci": "vendor/bin/simple-phpunit --coverage-text --coverage-clover=build/coverage.xml"
},
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
Expand Down
7 changes: 3 additions & 4 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.2/phpunit.xsd"
bootstrap="tests/bootstrap.php"
forceCoversAnnotation="true"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="VCR Plugin Test Suite">
Expand All @@ -13,7 +12,7 @@

<filter>
<whitelist>
<directory suffix=".php">.</directory>
<directory suffix=".php">src</directory>
<exclude>
<directory>./tests</directory>
<directory>./vendor</directory>
Expand Down
7 changes: 0 additions & 7 deletions src/Exception/CannotBeReplayed.php

This file was deleted.

7 changes: 0 additions & 7 deletions src/Exception/InvalidState.php

This file was deleted.

7 changes: 0 additions & 7 deletions src/Exception/NotFound.php

This file was deleted.

7 changes: 0 additions & 7 deletions src/Exception/Storage.php

This file was deleted.

7 changes: 0 additions & 7 deletions src/Exception/VcrException.php

This file was deleted.

17 changes: 17 additions & 0 deletions src/NamingStrategy/NamingStrategyInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Http\Client\Plugin\Vcr\NamingStrategy;

use Psr\Http\Message\RequestInterface;

/**
* Provides a deterministic and unique identifier for a request. The identifier must be safe to use with a filesystem.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
interface NamingStrategyInterface
{
public function name(RequestInterface $request): string;
}
90 changes: 90 additions & 0 deletions src/NamingStrategy/PathNamingStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace Http\Client\Plugin\Vcr\NamingStrategy;

use Psr\Http\Message\RequestInterface;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* Will use the request attributes (hostname, path, headers & body) as filename.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
class PathNamingStrategy implements NamingStrategyInterface
{
/**
* @var array
*/
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();
$this->configureOptions($resolver);
$this->options = $resolver->resolve($options);
}

public function name(RequestInterface $request): string
{
$parts = [$request->getUri()->getHost()];

$method = strtoupper($request->getMethod());

$parts[] = $method;
$parts[] = str_replace('/', '_', trim($request->getUri()->getPath(), '/'));
$parts[] = $this->getHeaderHash($request);

if ($query = $request->getUri()->getQuery()) {
$parts[] = $this->hash($query);
}

if (\in_array($method, $this->options['hash_body_methods'], true)) {
$parts[] = $this->hash((string) $request->getBody());
}

return implode('_', array_filter($parts));
}

private function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'hash_headers' => [],
Copy link
Contributor

Choose a reason for hiding this comment

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

i was wondering if we should add defaults here (COOKIE, AUTHORIZATION, ACCEPT, ACCEPT-ENCODING, ACCEPT-LANGUAGE) but i guess most of the time you don't care, and when you do you should explicitly configure the headers you need.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think configuration it's up to the user too

'hash_body_methods' => ['PUT', 'POST', 'PATCH'],
]);

$resolver->setAllowedTypes('hash_headers', 'string[]');
$resolver->setAllowedTypes('hash_body_methods', 'string[]');

$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));
}
}
50 changes: 50 additions & 0 deletions src/RecordPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Http\Client\Plugin\Vcr;

use Http\Client\Common\Plugin;
use Http\Client\Plugin\Vcr\NamingStrategy\NamingStrategyInterface;
use Http\Client\Plugin\Vcr\Recorder\RecorderInterface;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

final class RecordPlugin implements Plugin
{
const HEADER_NAME = 'X-VCR-RECORD';

/**
* @var NamingStrategyInterface
*/
private $namingStrategy;

/**
* @var RecorderInterface
*/
private $recorder;

public function __construct(NamingStrategyInterface $namingStrategy, RecorderInterface $recorder)
{
$this->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->hasHeader(ReplayPlugin::HEADER_NAME)) {
$this->recorder->record($name, $response);
$response = $response->withAddedHeader(static::HEADER_NAME, $name);
}

return $response;
});
}
}
81 changes: 81 additions & 0 deletions src/Recorder/FilesystemRecorder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Http\Client\Plugin\Vcr\Recorder;

use GuzzleHttp\Psr7;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;

/**
* Stores responses using the `guzzlehttp/psr7` library to serialize and deserialize the response.
* Target directory should be part of your VCS.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
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);
}
}
}
Loading