Skip to content
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