Skip to content

Commit f0d8907

Browse files
committed
feat(Storage): add support for idempotency token
1 parent 284f348 commit f0d8907

5 files changed

Lines changed: 289 additions & 3 deletions

File tree

Storage/src/Connection/Rest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<?php
2+
23
/**
34
* Copyright 2015 Google Inc. All Rights Reserved.
45
*
@@ -19,7 +20,6 @@
1920

2021
use Google\Auth\GetUniverseDomainInterface;
2122
use Google\Cloud\Core\RequestBuilder;
22-
use Google\Cloud\Core\RequestWrapper;
2323
use Google\Cloud\Core\RestTrait;
2424
use Google\Cloud\Core\Retry;
2525
use Google\Cloud\Core\Upload\AbstractUploader;
@@ -141,7 +141,7 @@ public function __construct(array $config = [])
141141

142142
$this->apiEndpoint = $this->getApiEndpoint(null, $config, self::DEFAULT_API_ENDPOINT_TEMPLATE);
143143

144-
$this->setRequestWrapper(new RequestWrapper($config));
144+
$this->setRequestWrapper(new StorageRequestWrapper($config));
145145
$this->setRequestBuilder(new RequestBuilder(
146146
$config['serviceDefinitionPath'],
147147
$this->apiEndpoint
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2024 Google Inc. All Rights Reserved.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
namespace Google\Cloud\Storage\Connection;
20+
21+
use Google\Cloud\Core\RequestWrapper;
22+
use Psr\Http\Message\RequestInterface;
23+
use Ramsey\Uuid\Uuid;
24+
25+
/**
26+
* A wrapper for requests which adds an Idempotency Token.
27+
*
28+
* @internal
29+
*/
30+
class StorageRequestWrapper extends RequestWrapper
31+
{
32+
/**
33+
* @param RequestInterface $request A PSR-7 request.
34+
* @param array $options [optional]
35+
* @return mixed
36+
*/
37+
public function send(RequestInterface $request, array $options = [])
38+
{
39+
$options = $this->addToken($request, $options);
40+
return parent::send($request, $options);
41+
}
42+
43+
/**
44+
* @param RequestInterface $request A PSR-7 request.
45+
* @param array $options [optional]
46+
* @return mixed
47+
*/
48+
public function sendAsync(RequestInterface $request, array $options = [])
49+
{
50+
$options = $this->addToken($request, $options);
51+
return parent::sendAsync($request, $options);
52+
}
53+
54+
/**
55+
* Helper to inject the token.
56+
*
57+
* @param RequestInterface $request
58+
* @param array $options
59+
* @return array
60+
*/
61+
private function addToken(RequestInterface $request, array $options)
62+
{
63+
$method = strtoupper($request->getMethod());
64+
if ($method === 'GET' || $method === 'HEAD' || $method === 'OPTIONS') {
65+
return $options;
66+
}
67+
68+
$hasTokenInOptions = false;
69+
if (isset($options['restOptions']['headers'])) {
70+
foreach ($options['restOptions']['headers'] as $key => $value) {
71+
if (strtolower($key) === 'x-goog-gcs-idempotency-token') {
72+
$hasTokenInOptions = true;
73+
break;
74+
}
75+
}
76+
}
77+
78+
if (!$hasTokenInOptions && !$request->hasHeader('x-goog-gcs-idempotency-token')) {
79+
$token = Uuid::uuid4()->toString();
80+
if (isset($options['retryHeaders'])) {
81+
foreach ($options['retryHeaders'] as $header) {
82+
if (strpos($header, 'gccl-invocation-id/') === 0) {
83+
$extractedToken = substr($header, 19);
84+
if ($extractedToken !== false && $extractedToken !== '') {
85+
$token = $extractedToken;
86+
}
87+
break;
88+
}
89+
}
90+
}
91+
$options['restOptions']['headers']['x-goog-gcs-idempotency-token'] = $token;
92+
}
93+
return $options;
94+
}
95+
}

Storage/tests/System/ManageObjectsTest.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,81 @@ public function testUploadAsync()
542542
$this->assertInstanceOf(StorageObject::class, $resp);
543543
}
544544

545+
public function testIdempotencyTokenRetries()
546+
{
547+
$name = uniqid(self::TESTING_PREFIX);
548+
$object = self::$bucket->upload('test data', [
549+
'name' => $name
550+
]);
551+
552+
$uuid = \Ramsey\Uuid\Uuid::uuid4()->toString();
553+
554+
// First delete will succeed
555+
$object->delete([
556+
'restOptions' => [
557+
'headers' => [
558+
'x-goog-gcs-idempotency-token' => $uuid
559+
]
560+
]
561+
]);
562+
563+
// Second delete uses the exact same UUID, simulating a network retry.
564+
// It should NOT throw a NotFoundException because the GCS backend
565+
// will recognize the token and return the cached success response.
566+
$object->delete([
567+
'restOptions' => [
568+
'headers' => [
569+
'x-goog-gcs-idempotency-token' => $uuid
570+
]
571+
]
572+
]);
573+
574+
$this->assertFalse($object->exists());
575+
}
576+
577+
public function testIdempotencyTokenUpdateRetriesWithPrecondition()
578+
{
579+
$name = uniqid(self::TESTING_PREFIX);
580+
$object = self::$bucket->upload('test data', [
581+
'name' => $name
582+
]);
583+
584+
$info = $object->info();
585+
$metageneration = $info['metageneration'];
586+
587+
$uuid = \Ramsey\Uuid\Uuid::uuid4()->toString();
588+
589+
$metadata = [
590+
'metadata' => [
591+
'location' => 'test'
592+
]
593+
];
594+
595+
// First update will succeed and increment the metageneration
596+
$object->update($metadata, [
597+
'ifMetagenerationMatch' => $metageneration,
598+
'restOptions' => [
599+
'headers' => [
600+
'x-goog-gcs-idempotency-token' => $uuid
601+
]
602+
]
603+
]);
604+
605+
// Second update uses the exact same UUID, simulating a network retry.
606+
// Even though the metageneration has changed, the backend recognizes
607+
// the idempotency token and returns 200 OK instead of 412 Precondition Failed.
608+
$object->update($metadata, [
609+
'ifMetagenerationMatch' => $metageneration,
610+
'restOptions' => [
611+
'headers' => [
612+
'x-goog-gcs-idempotency-token' => $uuid
613+
]
614+
]
615+
]);
616+
617+
$this->assertEquals('test', $object->info()['metadata']['location']);
618+
}
619+
545620
public function testUpdateObject()
546621
{
547622
$metadata = [

Storage/tests/Unit/Connection/RestTest.php

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
use Google\Cloud\Core\Upload\StreamableUploader;
2828
use Google\Cloud\Storage\Connection\Rest;
2929
use Google\Cloud\Storage\Connection\RetryTrait;
30+
use Google\Cloud\Storage\Connection\StorageRequestWrapper;
31+
3032
use GuzzleHttp\Client;
3133
use GuzzleHttp\Exception\RequestException;
3234
use GuzzleHttp\Promise\Create;
@@ -1030,6 +1032,119 @@ public function provideRetryHeaders()
10301032
];
10311033
}
10321034

1035+
public function testIdempotencyTokenHeaderAdded()
1036+
{
1037+
$mockClient = $this->prophesize(Client::class);
1038+
$mockClient->send(
1039+
Argument::type(RequestInterface::class),
1040+
Argument::that(function ($options) {
1041+
if (!isset($options['headers']['x-goog-gcs-idempotency-token'])) {
1042+
return false;
1043+
}
1044+
$token = $options['headers']['x-goog-gcs-idempotency-token'];
1045+
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $token) === 1;
1046+
})
1047+
)->willReturn(new Response(200, [], '{}'))->shouldBeCalled();
1048+
1049+
$rest = new Rest();
1050+
$rest->setRequestWrapper(new StorageRequestWrapper([
1051+
'httpHandler' => new Guzzle7HttpHandler($mockClient->reveal()),
1052+
'accessToken' => 'Fake token',
1053+
]));
1054+
1055+
$rest->insertBucket();
1056+
}
1057+
1058+
public function testIdempotencyTokenNotOverwrittenIfProvided()
1059+
{
1060+
$customToken = 'my-custom-uuid-1234';
1061+
1062+
$mockClient = $this->prophesize(Client::class);
1063+
$mockClient->send(
1064+
Argument::type(RequestInterface::class),
1065+
Argument::that(function ($options) use ($customToken) {
1066+
if (!isset($options['headers']['x-goog-gcs-idempotency-token'])) {
1067+
return false;
1068+
}
1069+
return $options['headers']['x-goog-gcs-idempotency-token'] === $customToken;
1070+
})
1071+
)->willReturn(new Response(200, [], '{}'))->shouldBeCalled();
1072+
1073+
$rest = new Rest();
1074+
$rest->setRequestWrapper(new StorageRequestWrapper([
1075+
'httpHandler' => new Guzzle7HttpHandler($mockClient->reveal()),
1076+
'accessToken' => 'Fake token',
1077+
]));
1078+
1079+
$rest->insertBucket([
1080+
'restOptions' => [
1081+
'headers' => [
1082+
'x-goog-gcs-idempotency-token' => $customToken
1083+
]
1084+
]
1085+
]);
1086+
}
1087+
1088+
public function testIdempotencyTokenNotOverwrittenIfProvidedWithMixedCase()
1089+
{
1090+
$customToken = 'my-custom-uuid-1234';
1091+
1092+
$mockClient = $this->prophesize(Client::class);
1093+
$mockClient->send(
1094+
Argument::type(RequestInterface::class),
1095+
Argument::that(function ($options) use ($customToken) {
1096+
if (isset($options['headers']['x-goog-gcs-idempotency-token'])) {
1097+
return false;
1098+
}
1099+
if (!isset($options['headers']['X-Goog-Gcs-Idempotency-Token'])) {
1100+
return false;
1101+
}
1102+
return $options['headers']['X-Goog-Gcs-Idempotency-Token'] === $customToken;
1103+
})
1104+
)->willReturn(new Response(200, [], '{}'))->shouldBeCalled();
1105+
1106+
$rest = new Rest();
1107+
$rest->setRequestWrapper(new StorageRequestWrapper([
1108+
'httpHandler' => new Guzzle7HttpHandler($mockClient->reveal()),
1109+
'accessToken' => 'Fake token',
1110+
]));
1111+
1112+
$rest->insertBucket([
1113+
'restOptions' => [
1114+
'headers' => [
1115+
'X-Goog-Gcs-Idempotency-Token' => $customToken
1116+
]
1117+
]
1118+
]);
1119+
}
1120+
1121+
public function testIdempotencyTokenGeneratedIfGcclInvocationIdMalformed()
1122+
{
1123+
$mockClient = $this->prophesize(Client::class);
1124+
$mockClient->send(
1125+
Argument::type(RequestInterface::class),
1126+
Argument::that(function ($options) {
1127+
if (!isset($options['headers']['x-goog-gcs-idempotency-token'])) {
1128+
return false;
1129+
}
1130+
$token = $options['headers']['x-goog-gcs-idempotency-token'];
1131+
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $token) === 1;
1132+
})
1133+
)->willReturn(new Response(200, [], '{}'))->shouldBeCalled();
1134+
1135+
$rest = new Rest();
1136+
$rest->setRequestWrapper(new StorageRequestWrapper([
1137+
'httpHandler' => new Guzzle7HttpHandler($mockClient->reveal()),
1138+
'accessToken' => 'Fake token',
1139+
]));
1140+
1141+
$rest->insertBucket([
1142+
'retryHeaders' => [
1143+
'gccl-invocation-id/'
1144+
]
1145+
]);
1146+
}
1147+
10331148
private function getContentTypeAndMetadata(RequestInterface $request)
10341149
{
10351150
// Resumable upload request

dev/tests/Snippet/ProductNeutralGuides/AuthenticationTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ public function testAuthenticationCredentialsFetcherOption()
8282
$this->assertInstanceOf(StorageClient::class, $client);
8383
$connection = (new ReflectionClass($client))->getProperty('connection')->getValue($client);
8484
$requestWrapper = (new ReflectionClass($connection))->getProperty('requestWrapper')->getValue($connection);
85-
$creds = (new ReflectionClass($requestWrapper))->getProperty('credentialsFetcher')->getValue($requestWrapper);
85+
$requestWrapperReflection = new ReflectionClass(\Google\Cloud\Core\RequestWrapper::class);
86+
$creds = $requestWrapperReflection->getProperty('credentialsFetcher')->getValue($requestWrapper);
8687
$this->assertInstanceOf(ServiceAccountCredentials::class, $creds);
8788
$this->assertEquals($clientEmail, $creds->getClientName());
8889
}

0 commit comments

Comments
 (0)