Skip to content

Commit

Permalink
Merge pull request #379 from php-vcr/record_identical_requests
Browse files Browse the repository at this point in the history
Record identical requests
  • Loading branch information
higidi committed Dec 19, 2022
2 parents 1b53492 + 63de5ce commit 3e8b2b9
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 14 deletions.
5 changes: 5 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ parameters:
count: 1
path: src/VCR/Videorecorder.php

-
message: "#^Cannot call method toArray\\(\\) on VCR\\\\Response\\|null\\.$#"
count: 5
path: tests/Unit/CassetteTest.php

-
message: "#^Parameter \\#2 \\$requestMatchers of method VCR\\\\Request\\:\\:matches\\(\\) expects array\\<callable\\(\\)\\: mixed\\>, array\\{array\\{'some', 'method'\\}\\} given\\.$#"
count: 1
Expand Down
18 changes: 12 additions & 6 deletions src/VCR/Cassette.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,38 @@ public function __construct(
) {
}

public function hasResponse(Request $request): bool
public function hasResponse(Request $request, int $index = 0): bool
{
return null !== $this->playback($request);
return null !== $this->playback($request, $index);
}

public function playback(Request $request): ?Response
public function playback(Request $request, int $index = 0): ?Response
{
foreach ($this->storage as $recording) {
$storedRequest = Request::fromArray($recording['request']);
if ($storedRequest->matches($request, $this->getRequestMatchers())) {

// Support legacy cassettes which do not have the 'index' key by setting the index to the searched one to
// always match this record if the request matches
$recording['index'] ??= $index;

if ($storedRequest->matches($request, $this->getRequestMatchers()) && $index == $recording['index']) {
return Response::fromArray($recording['response']);
}
}

return null;
}

public function record(Request $request, Response $response): void
public function record(Request $request, Response $response, int $index = 0): void
{
if ($this->hasResponse($request)) {
if ($this->hasResponse($request, $index)) {
return;
}

$this->storage->storeRecording([
'request' => $request->toArray(),
'response' => $response->toArray(),
'index' => $index,
]);
}

Expand Down
10 changes: 10 additions & 0 deletions src/VCR/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,14 @@ public function addPostFile(array $file): void
{
$this->postFiles[] = $file;
}

/**
* Generate a string representation of the request.
*
* @return string
*/
public function getHash()
{
return md5(serialize($this->toArray()));
}
}
2 changes: 1 addition & 1 deletion src/VCR/Storage/Storage.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
interface Storage extends \Iterator
{
/**
* @param array<string,string|array<string,mixed>|null> $recording
* @param array<string,int|string|array<string,mixed>|null> $recording
*/
public function storeRecording(array $recording): void;

Expand Down
39 changes: 33 additions & 6 deletions src/VCR/Videorecorder.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class Videorecorder

protected EventDispatcherInterface $eventDispatcher;

/**
* @var array<string, int>
*/
protected array $indexTable = [];

public function __construct(
protected Configuration $config,
protected HttpClient $client,
Expand Down Expand Up @@ -98,7 +103,9 @@ public function turnOff(): void
public function eject(): void
{
Assertion::true($this->isOn, 'Please turn on VCR before ejecting a cassette, use: VCR::turnOn().');

$this->cassette = null;
$this->resetIndex();
}

/**
Expand All @@ -116,6 +123,7 @@ public function insertCassette(string $cassetteName): void

$this->cassette = new Cassette($cassetteName, $this->config, $storage);
$this->enableLibraryHooks();
$this->resetIndex();
}

/**
Expand All @@ -131,12 +139,11 @@ public function configure(): Configuration
*
* If a request was already recorded on a cassette it's response is returned,
* otherwise the request is issued and it's response recorded (and returned).
*
* @api
*
* @throws \LogicException if the mode is set to none or once and
* the cassette did not have a matching response
*
* @api
*/
public function handleRequest(Request $request): Response
{
Expand All @@ -146,11 +153,16 @@ public function handleRequest(Request $request): Response

$this->dispatch(new BeforePlaybackEvent($request, $this->cassette), VCREvents::VCR_BEFORE_PLAYBACK);

$response = $this->cassette->playback($request);
// Add an index to the request to allow recording identical requests and play them back in the same sequence.
$index = $this->iterateIndex($request);
$response = $this->cassette->playback($request, $index);

// Playback succeeded and the recorded response can be returned.
if (!empty($response)) {
$this->dispatch(new AfterPlaybackEvent($request, $response, $this->cassette), VCREvents::VCR_AFTER_PLAYBACK);
$this->dispatch(
new AfterPlaybackEvent($request, $response, $this->cassette),
VCREvents::VCR_AFTER_PLAYBACK
);

return $response;
}
Expand All @@ -170,7 +182,7 @@ public function handleRequest(Request $request): Response
$this->dispatch(new AfterHttpRequestEvent($request, $response), VCREvents::VCR_AFTER_HTTP_REQUEST);

$this->dispatch(new BeforeRecordEvent($request, $response, $this->cassette), VCREvents::VCR_BEFORE_RECORD);
$this->cassette->record($request, $response);
$this->cassette->record($request, $response, $index);
} finally {
$this->enableLibraryHooks();
}
Expand Down Expand Up @@ -214,4 +226,19 @@ public function __destruct()
$this->turnOff();
}
}

protected function iterateIndex(Request $request): int
{
$hash = $request->getHash();
if (!isset($this->indexTable[$hash])) {
$this->indexTable[$hash] = -1;
}

return ++$this->indexTable[$hash];
}

public function resetIndex(): void
{
$this->indexTable = [];
}
}
80 changes: 80 additions & 0 deletions tests/Unit/CassetteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,84 @@ public function testHasResponseFound(): void

$this->assertTrue($this->cassette->hasResponse($request), 'Expected true if request was found.');
}

/**
* Ensure that if a second identical request is played back from a legacy
* cassette, the first response will be returned.
*/
public function testPlaybackOfIdenticalRequestsFromLegacyCassette(): void
{
$request1 = new Request('GET', 'https://example.com');
$response1 = new Response('200', [], 'response1');

$request2 = new Request('GET', 'https://example.com');
$response2 = new Response('200', [], 'response2');

// These are legacy recordings with no index keys.
$recordings = [
[
'request' => $request1->toArray(),
'response' => $response1->toArray(),
],
[
'request' => $request2->toArray(),
'response' => $response2->toArray(),
],
];

$cassette = $this->createCassetteWithRecordings($recordings);

$this->assertEquals($response1->toArray(), $cassette->playback($request1, 0)->toArray());
$this->assertEquals($response1->toArray(), $cassette->playback($request2, 1)->toArray());
}

/**
* Ensure that if a second identical request is played back from an cassette
* with indexed recordings, the response corresponding to the recording
* index will be returned.
*/
public function testPlaybackOfIdenticalRequests(): void
{
$request1 = new Request('GET', 'https://example.com');
$response1 = new Response('200', [], 'response1');

$request2 = new Request('GET', 'https://example.com');
$response2 = new Response('200', [], 'response2');

// These are recordings with index keys which support playback of
// multiple identical requests.
$recordings = [
[
'request' => $request1->toArray(),
'response' => $response1->toArray(),
'index' => 0,
],
[
'request' => $request2->toArray(),
'response' => $response2->toArray(),
'index' => 1,
],
];

$cassette = $this->createCassetteWithRecordings($recordings);

$this->assertEquals($response1->toArray(), $cassette->playback($request1, 0)->toArray());
$this->assertNotEquals($response1->toArray(), $cassette->playback($request2, 1)->toArray());
$this->assertEquals($response2->toArray(), $cassette->playback($request2, 1)->toArray());
}

/**
* @param array<int,array<string,int|string|array<string,mixed>|null>> $recordings
*/
protected function createCassetteWithRecordings(array $recordings): Cassette
{
$storage = new Yaml(vfsStream::url('test/'), 'json_test');

foreach ($recordings as $recording) {
$storage->storeRecording($recording);
}
$configuration = new Configuration();

return new Cassette('cassette_name', $configuration, $storage);
}
}
3 changes: 2 additions & 1 deletion tests/Unit/VideorecorderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ public function testInsertCassetteEjectExisting(): void
$configuration->enableLibraryHooks([]);
$videorecorder = $this->getMockBuilder('\VCR\Videorecorder')
->setConstructorArgs([$configuration, new HttpClient(), VCRFactory::getInstance()])
->setMethods(['eject'])
->setMethods(['eject', 'resetIndex'])
->getMock();

$videorecorder->expects($this->exactly(2))->method('eject');
$videorecorder->expects($this->exactly(2))->method('resetIndex');

$videorecorder->turnOn();
$videorecorder->insertCassette('cassette1');
Expand Down

0 comments on commit 3e8b2b9

Please sign in to comment.