Skip to content

Commit

Permalink
Merge pull request #380 from php-vcr/1.5-record_identical_requests
Browse files Browse the repository at this point in the history
[1.5] Record identical requests
  • Loading branch information
higidi committed Dec 19, 2022
2 parents 83c6abd + 7d17c69 commit 3cd0f74
Show file tree
Hide file tree
Showing 9 changed files with 294 additions and 12 deletions.
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ parameters:

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

-
Expand Down
18 changes: 12 additions & 6 deletions src/VCR/Cassette.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ public function __construct(string $name, Configuration $config, Storage $storag
*
* @return bool true if a response was recorded for specified request
*/
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);
}

/**
Expand All @@ -65,11 +65,16 @@ public function hasResponse(Request $request): bool
*
* @return Response|null response for specified request
*/
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'] = $recording['index'] ?? $index;

if ($storedRequest->matches($request, $this->getRequestMatchers()) && $index == $recording['index']) {
return Response::fromArray($recording['response']);
}
}
Expand All @@ -83,15 +88,16 @@ public function playback(Request $request): ?Response
* @param Request $request request to record
* @param Response $response response to record
*/
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;
}

$recording = [
'request' => $request->toArray(),
'response' => $response->toArray(),
'index' => $index,
];

$this->storage->storeRecording($recording);
Expand Down
10 changes: 10 additions & 0 deletions src/VCR/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -336,4 +336,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
/**
* Stores an array of data.
*
* @param array<string,string|array<string,mixed>|null> $recording array to store in storage
* @param array<string,int|string|array<string,mixed>|null> $recording array to store in storage
*/
public function storeRecording(array $recording): void;

Expand Down
35 changes: 32 additions & 3 deletions src/VCR/Videorecorder.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ class Videorecorder
*/
protected $cassette;

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

/**
* @var bool flag if this videorecorder is turned on or not
*/
Expand Down Expand Up @@ -153,7 +158,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 @@ -177,6 +184,7 @@ public function insertCassette(string $cassetteName): void

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

/**
Expand Down Expand Up @@ -206,6 +214,8 @@ public function configure(): Configuration
* @throws \BadMethodCallException if there was no cassette inserted
* @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 @@ -216,12 +226,16 @@ public function handleRequest(Request $request): Response
$event = new BeforePlaybackEvent($request, $this->cassette);
$this->dispatch($event, 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)) {
$event = new AfterPlaybackEvent($request, $response, $this->cassette);
$this->dispatch($event, VCREvents::VCR_AFTER_PLAYBACK);
$this->dispatch($event,
VCREvents::VCR_AFTER_PLAYBACK
);

return $response;
}
Expand All @@ -241,7 +255,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 @@ -291,4 +305,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 @@ -66,4 +66,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 @@ -35,10 +35,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
104 changes: 104 additions & 0 deletions tests/fixtures/unittest_curl_test
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,107 @@
starttransfer_time: 0.194476
certinfo: { }
primary_port: 80
-
request:
method: GET
url: 'http://google.com/'
headers:
Host: google.com
response:
status:
http_version: '1.1'
code: '301'
message: 'Moved Permanently'
headers:
Location: 'http://www.google.com/'
Content-Type: 'text/html; charset=UTF-8'
Cross-Origin-Opener-Policy-Report-Only: 'same-origin-allow-popups; report-to="gws"'
Report-To: '{"group":"gws","max_age":2592000,"endpoints":[{"url":"https://csp.withgoogle.com/csp/report-to/gws/other"}]}'
Date: 'Mon, 19 Dec 2022 18:14:52 GMT'
Expires: 'Wed, 18 Jan 2023 18:14:52 GMT'
Cache-Control: 'public, max-age=2592000'
Server: gws
Content-Length: '219'
X-XSS-Protection: '0'
X-Frame-Options: SAMEORIGIN
body: "<HTML><HEAD><meta http-equiv=\"content-type\" content=\"text/html;charset=utf-8\">\n<TITLE>301 Moved</TITLE></HEAD><BODY>\n<H1>301 Moved</H1>\nThe document has moved\n<A HREF=\"http://www.google.com/\">here</A>.\r\n</BODY></HTML>\r\n"
curl_info:
url: 'http://google.com/'
content_type: 'text/html; charset=UTF-8'
http_code: 301
header_size: 513
request_size: 49
filetime: -1
ssl_verify_result: 0
redirect_count: 0
total_time: 0.123476
namelookup_time: 0.057769
connect_time: 0.078418
pretransfer_time: 0.078528
size_upload: 0.0
size_download: 219.0
speed_download: 1780.0
speed_upload: 0.0
download_content_length: 219.0
upload_content_length: -1.0
starttransfer_time: 0.123428
redirect_time: 0.0
redirect_url: 'http://www.google.com/'
primary_ip: 172.217.16.174
certinfo: { }
primary_port: 80
local_ip: 172.18.0.2
local_port: 34248
index: 0
-
request:
method: GET
url: 'http://google.com/'
headers:
Host: google.com
response:
status:
http_version: '1.1'
code: '301'
message: 'Moved Permanently'
headers:
Location: 'http://www.google.com/'
Content-Type: 'text/html; charset=UTF-8'
Cross-Origin-Opener-Policy-Report-Only: 'same-origin-allow-popups; report-to="gws"'
Report-To: '{"group":"gws","max_age":2592000,"endpoints":[{"url":"https://csp.withgoogle.com/csp/report-to/gws/other"}]}'
Date: 'Mon, 19 Dec 2022 18:14:52 GMT'
Expires: 'Wed, 18 Jan 2023 18:14:52 GMT'
Cache-Control: 'public, max-age=2592000'
Server: gws
Content-Length: '219'
X-XSS-Protection: '0'
X-Frame-Options: SAMEORIGIN
body: "<HTML><HEAD><meta http-equiv=\"content-type\" content=\"text/html;charset=utf-8\">\n<TITLE>301 Moved</TITLE></HEAD><BODY>\n<H1>301 Moved</H1>\nThe document has moved\n<A HREF=\"http://www.google.com/\">here</A>.\r\n</BODY></HTML>\r\n"
curl_info:
url: 'http://google.com/'
content_type: 'text/html; charset=UTF-8'
http_code: 301
header_size: 513
request_size: 49
filetime: -1
ssl_verify_result: 0
redirect_count: 0
total_time: 0.068302
namelookup_time: 0.004149
connect_time: 0.022267
pretransfer_time: 0.022427
size_upload: 0.0
size_download: 219.0
speed_download: 3220.0
speed_upload: 0.0
download_content_length: 219.0
upload_content_length: -1.0
starttransfer_time: 0.068265
redirect_time: 0.0
redirect_url: 'http://www.google.com/'
primary_ip: 172.217.16.174
certinfo: { }
primary_port: 80
local_ip: 172.18.0.2
local_port: 34264
index: 1

0 comments on commit 3cd0f74

Please sign in to comment.