Skip to content

[Plugin] Add a plugin to record and replay responses #172

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

Closed
Closed
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
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
"guzzlehttp/psr7": "^1.4",
"phpspec/phpspec": "^5.1",
"phpspec/prophecy": "^1.8",
"sebastian/comparator": "^3.0"
"sebastian/comparator": "^3.0",
"symfony/filesystem": " ^3.4.20 || ^4.0.15 || ^4.1.9 || ^4.2.1",
"symfony/phpunit-bridge": "*"
},
"suggest": {
"ext-json": "To detect JSON responses with the ContentTypePlugin",
Expand All @@ -38,7 +40,8 @@
},
"autoload-dev": {
"psr-4": {
"spec\\Http\\Client\\Common\\": "spec/"
"spec\\Http\\Client\\Common\\": "spec/",
"tests\\Http\\Client\\Common\\": "tests/"
}
},
"scripts": {
Expand Down
17 changes: 17 additions & 0 deletions src/Plugin/NamingStrategyInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Http\Client\Common\Plugin;

use Psr\Http\Message\RequestInterface;

/**
* Provides a unique name to identify a request.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
interface NamingStrategyInterface
{
public function name(RequestInterface $request): string;
}
73 changes: 73 additions & 0 deletions src/Plugin/RecordAndReplayPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace Http\Client\Common\Plugin;

use GuzzleHttp\Psr7;
Copy link
Contributor

Choose a reason for hiding this comment

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

can we read and write requests without a dependency on guzzle? and does that thing handle any response or only guzzle responses?

afaik there is no PSR for (de)serializing responses. if we need to depend on guzzle for it, we should create a separate repository for this plugin that depends on guzzle psr7.

Copy link
Author

Choose a reason for hiding this comment

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

Sadly it only "deserialize" Guzzle Response, but can serialize any PSR 7 compatible Message. Adding a "mode" (record, replay or both) is a good idea, I will add it. Adding a new repo would justify the dependency on Guzzzle PSR7 and Symfony's filesystem as well (And PHPUnit in dev), but how can we proceed to create it?

Copy link
Contributor

Choose a reason for hiding this comment

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

i can create an empty repository, then you can move the code to a pull request to that repository. like e.g. https://github.com/php-http/logger-plugin

i suggest the name php-http/vcr-plugin. record-and-replay-plugin is too long, and recorder-plugin is incomplete. when we agree on the name, i can create the repo.

Copy link
Author

Choose a reason for hiding this comment

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

vcr-plugin is fine to me :)

Copy link
Contributor

Choose a reason for hiding this comment

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

turns out the https://github.com/php-http/vcr-plugin repository already exists and jerome started something but abandoned it. @sagikazarmark says he talked with jerome and he does not intend to continue.

please do a pull request on it where you completely replace whats in there. jerome started to implement the whole recording and all, but i find your approach much better as there is less code to maintain.

Copy link
Author

Choose a reason for hiding this comment

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

Perfect! I'll start working on this then

Copy link
Author

Choose a reason for hiding this comment

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

use Http\Client\Common\Plugin;
use Http\Promise\FulfilledPromise;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Filesystem\Filesystem;

/**
* Record successful responses into the filesystem and replay the response when a similar request is performed (VCR-like).
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
final class RecordAndReplayPlugin implements Plugin
{
/**
* Return a unique name to identify a given request.
*
* @var NamingStrategyInterface
*/
private $namingStrategy;

/**
* The directory containing your fixtures (Must be writable).
*
* @var string
*/
private $directory;

/**
* @var Filesystem
*/
private $fs;

public function __construct(NamingStrategyInterface $namingStrategy, string $directory, ?Filesystem $fs = null)
{
$this->namingStrategy = $namingStrategy;
$this->directory = $directory;
$this->fs = $fs ?? new Filesystem();
}

/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
if (!$this->fs->exists($this->directory)) {
$this->fs->mkdir($this->directory);
}

$directory = realpath($this->directory);
$name = $this->namingStrategy->name($request);
$filename = "$directory/$name.txt";

if ($this->fs->exists($filename)) {
return new FulfilledPromise(Psr7\parse_response(file_get_contents($filename)));
}

return $next($request)->then(function (ResponseInterface $response) use ($filename) {
if ($response->getStatusCode() < 300) {
$this->fs->dumpFile($filename, Psr7\str($response));
}

return $response;
});
}
}
83 changes: 83 additions & 0 deletions tests/Plugin/RecordAndReplayPluginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace tests\Http\Client\Common\Plugin;

use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\Psr7\str;
use Http\Client\Common\Plugin\NamingStrategyInterface;
use Http\Client\Common\Plugin\RecordAndReplayPlugin;
use Http\Promise\FulfilledPromise;
use Http\Promise\Promise;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use spec\Http\Client\Common\Plugin\PluginStub;
use Symfony\Component\Filesystem\Filesystem;

/**
* @author Gary PEGEOT <garypegeot@gmail.com>
*
* @internal
*/
final class RecordAndReplayPluginTest extends TestCase
{
/**
* @var NamingStrategyInterface|MockObject
*/
private $strategy;

private $directory;

/**
* @var Filesystem
*/
private $fs;

/**
* @var RecordAndReplayPlugin
*/
private $plugin;

protected function setUp(): void
{
$this->strategy = $this->createMock(NamingStrategyInterface::class);
$this->directory = sys_get_temp_dir().\DIRECTORY_SEPARATOR.md5(random_bytes(10));
$this->fs = new Filesystem();
$this->plugin = new RecordAndReplayPlugin($this->strategy, $this->directory, $this->fs);
}

protected function tearDown(): void
{
if ($this->fs->exists($this->directory)) {
$this->fs->remove($this->directory);
}
}

public function testHandleRequest(): void
{
/** @var RequestInterface $request */
$request = $this->createMock(RequestInterface::class);
$next = function (): Promise {
return new FulfilledPromise(new Response(200, ['X-Foo' => 'Bar'], '{"baz": true}'));
};
$filename = "$this->directory/foo.txt";
$first = PluginStub::first();

$this->assertFileNotExists($filename, 'File should not exists yet');

$this->strategy->expects($this->any())->method('name')->with($request)->willReturn('foo');

$response = $this->plugin->handleRequest($request, $next, $first)->wait();

$this->assertInstanceOf(Response::class, $response);
$this->assertFileExists($filename, 'File should be created');
$this->assertStringEqualsFile($filename, str($response));

$next = function (): void {
$this->fail('Next should not be called when the fixture file exists');
};
$this->assertSame(str($response), str($this->plugin->handleRequest($request, $next, $first)->wait()));
}
}