diff --git a/Storage/src/Connection/Rest.php b/Storage/src/Connection/Rest.php index 9388839e8bb4..7bd1d66ea059 100644 --- a/Storage/src/Connection/Rest.php +++ b/Storage/src/Connection/Rest.php @@ -19,7 +19,6 @@ use Google\Auth\GetUniverseDomainInterface; use Google\Cloud\Core\RequestBuilder; -use Google\Cloud\Core\RequestWrapper; use Google\Cloud\Core\RestTrait; use Google\Cloud\Core\Retry; use Google\Cloud\Core\Upload\AbstractUploader; @@ -141,7 +140,7 @@ public function __construct(array $config = []) $this->apiEndpoint = $this->getApiEndpoint(null, $config, self::DEFAULT_API_ENDPOINT_TEMPLATE); - $this->setRequestWrapper(new RequestWrapper($config)); + $this->setRequestWrapper(new StorageRequestWrapper($config)); $this->setRequestBuilder(new RequestBuilder( $config['serviceDefinitionPath'], $this->apiEndpoint diff --git a/Storage/src/Connection/StorageRequestWrapper.php b/Storage/src/Connection/StorageRequestWrapper.php new file mode 100644 index 000000000000..acd6e222502a --- /dev/null +++ b/Storage/src/Connection/StorageRequestWrapper.php @@ -0,0 +1,95 @@ +addToken($request, $options); + return parent::send($request, $options); + } + + /** + * @param RequestInterface $request A PSR-7 request. + * @param array $options [optional] + * @return mixed + */ + public function sendAsync(RequestInterface $request, array $options = []) + { + $options = $this->addToken($request, $options); + return parent::sendAsync($request, $options); + } + + /** + * Helper to inject the token. + * + * @param RequestInterface $request + * @param array $options + * @return array + */ + private function addToken(RequestInterface $request, array $options) + { + $method = strtoupper($request->getMethod()); + if (in_array($method, ['GET', 'HEAD', 'OPTIONS'])) { + return $options; + } + + $hasTokenInOptions = false; + if (isset($options['restOptions']['headers'])) { + foreach ($options['restOptions']['headers'] as $key => $value) { + if (strtolower($key) === 'x-goog-gcs-idempotency-token') { + $hasTokenInOptions = true; + break; + } + } + } + + if (!$hasTokenInOptions && !$request->hasHeader('x-goog-gcs-idempotency-token')) { + $token = Uuid::uuid4()->toString(); + if (isset($options['retryHeaders'])) { + foreach ($options['retryHeaders'] as $header) { + if (strpos($header, 'gccl-invocation-id/') === 0) { + $extractedToken = substr($header, 19); + if ($extractedToken !== false && $extractedToken !== '') { + $token = $extractedToken; + } + break; + } + } + } + $options['restOptions']['headers']['x-goog-gcs-idempotency-token'] = $token; + } + return $options; + } +} diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index de8bde92d072..fefa57d93cdb 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -22,6 +22,7 @@ use Google\Cloud\Storage\StorageObject; use GuzzleHttp\Promise\PromiseInterface; use Psr\Http\Message\StreamInterface; +use Ramsey\Uuid\Uuid; /** * @group storage @@ -542,6 +543,81 @@ public function testUploadAsync() $this->assertInstanceOf(StorageObject::class, $resp); } + public function testIdempotencyTokenRetries() + { + $name = uniqid(self::TESTING_PREFIX); + $object = self::$bucket->upload('test data', [ + 'name' => $name + ]); + + $uuid = Uuid::uuid4()->toString(); + + // First delete will succeed + $object->delete([ + 'restOptions' => [ + 'headers' => [ + 'x-goog-gcs-idempotency-token' => $uuid + ] + ] + ]); + + // Second delete uses the exact same UUID, simulating a network retry. + // It should NOT throw a NotFoundException because the GCS backend + // will recognize the token and return the cached success response. + $object->delete([ + 'restOptions' => [ + 'headers' => [ + 'x-goog-gcs-idempotency-token' => $uuid + ] + ] + ]); + + $this->assertFalse($object->exists()); + } + + public function testIdempotencyTokenUpdateRetriesWithPrecondition() + { + $name = uniqid(self::TESTING_PREFIX); + $object = self::$bucket->upload('test data', [ + 'name' => $name + ]); + + $info = $object->info(); + $metageneration = $info['metageneration']; + + $uuid = Uuid::uuid4()->toString(); + + $metadata = [ + 'metadata' => [ + 'location' => 'test' + ] + ]; + + // First update will succeed and increment the metageneration + $object->update($metadata, [ + 'ifMetagenerationMatch' => $metageneration, + 'restOptions' => [ + 'headers' => [ + 'x-goog-gcs-idempotency-token' => $uuid + ] + ] + ]); + + // Second update uses the exact same UUID, simulating a network retry. + // Even though the metageneration has changed, the backend recognizes + // the idempotency token and returns 200 OK instead of 412 Precondition Failed. + $object->update($metadata, [ + 'ifMetagenerationMatch' => $metageneration, + 'restOptions' => [ + 'headers' => [ + 'x-goog-gcs-idempotency-token' => $uuid + ] + ] + ]); + + $this->assertEquals('test', $object->info()['metadata']['location']); + } + public function testUpdateObject() { $metadata = [ diff --git a/Storage/tests/Unit/Connection/RestTest.php b/Storage/tests/Unit/Connection/RestTest.php index d2afc3ddf590..945a4b4dfbf6 100644 --- a/Storage/tests/Unit/Connection/RestTest.php +++ b/Storage/tests/Unit/Connection/RestTest.php @@ -17,18 +17,22 @@ namespace Google\Cloud\Storage\Tests\Unit\Connection; -use Google\Auth\HttpHandler\Guzzle7HttpHandler; +use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Cloud\Core\RequestBuilder; use Google\Cloud\Core\RequestWrapper; use Google\Cloud\Core\Retry; -use Google\Cloud\Core\Testing\TestHelpers; use Google\Cloud\Core\Upload\MultipartUploader; use Google\Cloud\Core\Upload\ResumableUploader; use Google\Cloud\Core\Upload\StreamableUploader; use Google\Cloud\Storage\Connection\Rest; use Google\Cloud\Storage\Connection\RetryTrait; +use Google\Cloud\Storage\Connection\StorageRequestWrapper; use GuzzleHttp\Client; use GuzzleHttp\Exception\BadResponseException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Request; @@ -517,7 +521,7 @@ function ($args) use ( } ); $requestWrapper = new RequestWrapper([ - 'httpHandler' => new Guzzle7HttpHandler($mockClient->reveal()), + 'httpHandler' => HttpHandlerFactory::build($mockClient->reveal()), 'accessToken' => 'Fake token', 'retries' => 3, ]); @@ -1030,6 +1034,195 @@ public function provideRetryHeaders() ]; } + public function testIdempotencyTokenHeaderAdded() + { + $mockClient = $this->prophesize(Client::class); + $mockClient->send( + Argument::type(RequestInterface::class), + Argument::that(function ($options) { + if (!isset($options['headers']['x-goog-gcs-idempotency-token'])) { + return false; + } + $token = $options['headers']['x-goog-gcs-idempotency-token']; + 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; + }) + )->willReturn(new Response(200, [], '{}'))->shouldBeCalled(); + + $rest = new Rest(); + $rest->setRequestWrapper(new StorageRequestWrapper([ + 'httpHandler' => HttpHandlerFactory::build($mockClient->reveal()), + 'accessToken' => 'Fake token', + ])); + + $rest->insertBucket(); + } + + public function testIdempotencyTokenNotOverwrittenIfProvided() + { + $customToken = 'my-custom-uuid-1234'; + + $mockClient = $this->prophesize(Client::class); + $mockClient->send( + Argument::type(RequestInterface::class), + Argument::that(function ($options) use ($customToken) { + if (!isset($options['headers']['x-goog-gcs-idempotency-token'])) { + return false; + } + return $options['headers']['x-goog-gcs-idempotency-token'] === $customToken; + }) + )->willReturn(new Response(200, [], '{}'))->shouldBeCalled(); + + $rest = new Rest(); + $rest->setRequestWrapper(new StorageRequestWrapper([ + 'httpHandler' => HttpHandlerFactory::build($mockClient->reveal()), + 'accessToken' => 'Fake token', + ])); + + $rest->insertBucket([ + 'restOptions' => [ + 'headers' => [ + 'x-goog-gcs-idempotency-token' => $customToken + ] + ] + ]); + } + + public function testIdempotencyTokenNotOverwrittenIfProvidedWithMixedCase() + { + $customToken = 'my-custom-uuid-1234'; + + $mockClient = $this->prophesize(Client::class); + $mockClient->send( + Argument::type(RequestInterface::class), + Argument::that(function ($options) use ($customToken) { + if (isset($options['headers']['x-goog-gcs-idempotency-token'])) { + return false; + } + if (!isset($options['headers']['X-Goog-Gcs-Idempotency-Token'])) { + return false; + } + return $options['headers']['X-Goog-Gcs-Idempotency-Token'] === $customToken; + }) + )->willReturn(new Response(200, [], '{}'))->shouldBeCalled(); + + $rest = new Rest(); + $rest->setRequestWrapper(new StorageRequestWrapper([ + 'httpHandler' => HttpHandlerFactory::build($mockClient->reveal()), + 'accessToken' => 'Fake token', + ])); + + $rest->insertBucket([ + 'restOptions' => [ + 'headers' => [ + 'X-Goog-Gcs-Idempotency-Token' => $customToken + ] + ] + ]); + } + + public function testIdempotencyTokenGeneratedIfGcclInvocationIdMalformed() + { + $mockClient = $this->prophesize(Client::class); + $mockClient->send( + Argument::type(RequestInterface::class), + Argument::that(function ($options) { + if (!isset($options['headers']['x-goog-gcs-idempotency-token'])) { + return false; + } + $token = $options['headers']['x-goog-gcs-idempotency-token']; + 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; + }) + )->willReturn(new Response(200, [], '{}'))->shouldBeCalled(); + + $rest = new Rest(); + $rest->setRequestWrapper(new StorageRequestWrapper([ + 'httpHandler' => HttpHandlerFactory::build($mockClient->reveal()), + 'accessToken' => 'Fake token', + ])); + + $rest->insertBucket([ + 'retryHeaders' => [ + 'gccl-invocation-id/' + ] + ]); + } + + /** + * Test idempotency token and custom headers are preserved across retries. + */ + public function testIdempotencyTokenReusedOnRetry() + { + $container = []; + $history = Middleware::history($container); + + $mockHandler = new MockHandler([ + new Response(500, [], 'Internal Server Error'), + new Response(200, [], '{}') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($history); + + $client = new Client(['handler' => $handlerStack]); + + $customToken = 'my-custom-idempotency-token-12345'; + $contentType = 'application/json'; + $customHeader = 'my-custom-header-value'; + + $rest = new Rest([ + 'restDelayFunction' => function () { + }, + ]); + $rest->setRequestWrapper(new StorageRequestWrapper([ + 'httpHandler' => HttpHandlerFactory::build($client), + 'accessToken' => 'Fake token', + 'restDelayFunction' => function () { + }, + ])); + + $rest->insertBucket([ + 'restOptions' => [ + 'headers' => [ + 'x-goog-gcs-idempotency-token' => $customToken, + 'Content-Type' => $contentType, + 'X-Custom-Header' => $customHeader, + ] + ] + ]); + + $this->assertCount(2, $container); + + $firstRequest = $container[0]['request']; + $secondRequest = $container[1]['request']; + + $this->assertEquals( + $customToken, + $firstRequest->getHeaderLine('x-goog-gcs-idempotency-token') + ); + $this->assertEquals( + $firstRequest->getHeaderLine('x-goog-gcs-idempotency-token'), + $secondRequest->getHeaderLine('x-goog-gcs-idempotency-token') + ); + + $this->assertEquals( + $contentType, + $firstRequest->getHeaderLine('Content-Type') + ); + $this->assertEquals( + $firstRequest->getHeaderLine('Content-Type'), + $secondRequest->getHeaderLine('Content-Type') + ); + + $this->assertEquals( + $customHeader, + $firstRequest->getHeaderLine('X-Custom-Header') + ); + $this->assertEquals( + $firstRequest->getHeaderLine('X-Custom-Header'), + $secondRequest->getHeaderLine('X-Custom-Header') + ); + } + private function getContentTypeAndMetadata(RequestInterface $request) { // Resumable upload request diff --git a/dev/tests/Snippet/ProductNeutralGuides/AuthenticationTest.php b/dev/tests/Snippet/ProductNeutralGuides/AuthenticationTest.php index 6c02a31a2d76..e1821281a5f1 100644 --- a/dev/tests/Snippet/ProductNeutralGuides/AuthenticationTest.php +++ b/dev/tests/Snippet/ProductNeutralGuides/AuthenticationTest.php @@ -82,7 +82,10 @@ public function testAuthenticationCredentialsFetcherOption() $this->assertInstanceOf(StorageClient::class, $client); $connection = (new ReflectionClass($client))->getProperty('connection')->getValue($client); $requestWrapper = (new ReflectionClass($connection))->getProperty('requestWrapper')->getValue($connection); - $creds = (new ReflectionClass($requestWrapper))->getProperty('credentialsFetcher')->getValue($requestWrapper); + $requestWrapperReflection = new ReflectionClass(\Google\Cloud\Core\RequestWrapper::class); + $credentialsFetcherProperty = $requestWrapperReflection->getProperty('credentialsFetcher'); + $credentialsFetcherProperty->setAccessible(true); + $creds = $credentialsFetcherProperty->getValue($requestWrapper); $this->assertInstanceOf(ServiceAccountCredentials::class, $creds); $this->assertEquals($clientEmail, $creds->getClientName()); }